fix(backend,orchestrator,chat): tour humain agent_send survit au self-restart — #290 (le tour humain direct agent_send/ws chat.send s'enregistre au RestartRendezvousCoordinator via StructuredHumanTurnGuard: Final/Error de l'ancien provider devient transition de contrôle et non le Final logique; transition_after_provider_terminal_async attend Ready puis accept_ready acquitte le handoff et libère le scheduler, Resume => drain de la session de remplacement avec SELF_RESTART_CONTINUATION_PROMPT, seul le terminal du remplacement est transmis au chat; Failed/Cancelled surfacés explicitement avec libération Busy/FIFO; send_with_tap en erreur pendant restart actif traité comme teardown attendu (stream vide puis attente du remplacement, plus de Final prématuré ni chat bloqué); StopLiveAgent câblé with_restart_rendezvous: stop utilisateur annule le rendez-vous restart; RestartRendezvousRegistration rendu public (state/changed/accept_ready); QA verte: backend self_restart_contract_tests 3/3 dont 2 nouveaux (old terminal ignoré + handoff accepté, failure explicite + busy libéré), application orchestrator_service restart 8/8 dont registration directe du rendez-vous, workstate_actions 9/9, app-tauri chat_bridge 31/31, web-server websocket_chat_send 3/3, cargo check --workspace, fmt --check, diff --check — reconfirmés par Git avant commit; réserves hors périmètre préexistantes: 7 échecs fixture PTY web-server + 2 échecs plugin application; carnet #290 + clôture store #289 inclus; scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -1072,6 +1072,7 @@ fn self_restart_launch_input(
|
||||
#[cfg(test)]
|
||||
mod self_restart_contract_tests {
|
||||
use super::*;
|
||||
use domain::input::InputMediator;
|
||||
|
||||
#[test]
|
||||
fn relaunch_preserves_conversation_and_never_requests_fresh_start() {
|
||||
@ -1104,6 +1105,77 @@ mod self_restart_contract_tests {
|
||||
assert!(input.require_structured);
|
||||
assert!(!input.fresh_start);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn human_turn_ignores_old_terminal_and_accepts_replacement() {
|
||||
let mailbox = Arc::new(InMemoryMailbox::new());
|
||||
let input = Arc::new(MediatedInbox::new(
|
||||
Arc::clone(&mailbox),
|
||||
Arc::new(SystemMillisClock),
|
||||
));
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let project_id = ProjectId::from_uuid(Uuid::from_u128(290));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(1));
|
||||
let key = domain::RuntimeAgentKey::new(project_id, agent_id);
|
||||
let mut turn = StructuredHumanTurnGuard::start(
|
||||
Arc::clone(&input) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
Arc::clone(&coordinator),
|
||||
project_id,
|
||||
agent_id,
|
||||
"restart me",
|
||||
);
|
||||
let restart_id = "restart-human-290".to_owned();
|
||||
|
||||
assert!(coordinator.begin(key, restart_id.clone()));
|
||||
coordinator.ready(key, &restart_id);
|
||||
assert_eq!(
|
||||
turn.transition_after_provider_terminal_async().await,
|
||||
StructuredHumanTurnTransition::Resume,
|
||||
"the old provider terminal is a control transition, not the human Final"
|
||||
);
|
||||
coordinator
|
||||
.wait_for_handoff(key, &restart_id)
|
||||
.await
|
||||
.expect("replacement handoff is acknowledged");
|
||||
|
||||
turn.finish();
|
||||
assert_eq!(input.busy_state(key), domain::input::AgentBusyState::Idle);
|
||||
assert_eq!(mailbox.pending(&key), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn human_turn_surfaces_explicit_restart_failure_and_releases_busy() {
|
||||
let mailbox = Arc::new(InMemoryMailbox::new());
|
||||
let input = Arc::new(MediatedInbox::new(
|
||||
Arc::clone(&mailbox),
|
||||
Arc::new(SystemMillisClock),
|
||||
));
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let project_id = ProjectId::from_uuid(Uuid::from_u128(290));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(2));
|
||||
let key = domain::RuntimeAgentKey::new(project_id, agent_id);
|
||||
let mut turn = StructuredHumanTurnGuard::start(
|
||||
Arc::clone(&input) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
Arc::clone(&coordinator),
|
||||
project_id,
|
||||
agent_id,
|
||||
"restart me",
|
||||
);
|
||||
let restart_id = "restart-human-failure-290".to_owned();
|
||||
|
||||
assert!(coordinator.begin(key, restart_id.clone()));
|
||||
coordinator.fail(key, &restart_id, "replacement failed".to_owned());
|
||||
assert_eq!(
|
||||
turn.transition_after_provider_terminal_async().await,
|
||||
StructuredHumanTurnTransition::Failed("replacement failed".to_owned())
|
||||
);
|
||||
|
||||
turn.finish();
|
||||
assert_eq!(input.busy_state(key), domain::input::AgentBusyState::Idle);
|
||||
assert_eq!(mailbox.pending(&key), 0);
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
|
||||
@ -1306,6 +1378,7 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
|
||||
let turn = StructuredHumanTurnGuard::start(
|
||||
Arc::clone(&input),
|
||||
Arc::clone(&mailbox),
|
||||
Arc::clone(&rendezvous),
|
||||
project.id,
|
||||
agent,
|
||||
application::SELF_RESTART_CONTINUATION_PROMPT,
|
||||
@ -1361,23 +1434,39 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter {
|
||||
///
|
||||
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
|
||||
/// through `AgentSession::send`, but they must share the same busy/FIFO accounting
|
||||
/// as structured inter-agent asks. This guard starts a silent human turn and
|
||||
/// retires it on terminal outcome or drop.
|
||||
/// as structured inter-agent asks. This guard starts a silent human turn,
|
||||
/// registers it for restart handoff, and retires it only on the logical terminal
|
||||
/// outcome or drop.
|
||||
pub struct StructuredHumanTurnGuard {
|
||||
input: Arc<dyn domain::input::InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
agent: domain::RuntimeAgentKey,
|
||||
ticket_id: domain::mailbox::TicketId,
|
||||
armed: Arc<std::sync::atomic::AtomicBool>,
|
||||
restart: Option<application::RestartRendezvousRegistration>,
|
||||
finish_on_drop: bool,
|
||||
}
|
||||
|
||||
/// Control-plane outcome observed when one concrete provider turn terminates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StructuredHumanTurnTransition {
|
||||
/// No self-restart owns this terminal event; it is authoritative for the UI.
|
||||
Complete,
|
||||
/// The old terminal event is superseded; continue on the replacement runtime.
|
||||
Resume,
|
||||
/// Replacement failed explicitly.
|
||||
Failed(String),
|
||||
/// The user cancelled the logical turn while replacement was in flight.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl StructuredHumanTurnGuard {
|
||||
/// Starts the mediated busy turn for a direct human structured chat message.
|
||||
#[must_use]
|
||||
pub fn start(
|
||||
input: Arc<dyn domain::input::InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
restart_rendezvous: Arc<application::RestartRendezvousCoordinator>,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
prompt: &str,
|
||||
@ -1397,6 +1486,7 @@ impl StructuredHumanTurnGuard {
|
||||
agent,
|
||||
ticket_id,
|
||||
armed: Arc::new(std::sync::atomic::AtomicBool::new(owns_turn)),
|
||||
restart: owns_turn.then(|| restart_rendezvous.register(agent)),
|
||||
finish_on_drop: true,
|
||||
}
|
||||
}
|
||||
@ -1410,10 +1500,55 @@ impl StructuredHumanTurnGuard {
|
||||
agent: self.agent,
|
||||
ticket_id: self.ticket_id,
|
||||
armed: Arc::clone(&self.armed),
|
||||
restart: None,
|
||||
finish_on_drop: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves whether a concrete provider terminal event really ends the
|
||||
/// logical human turn. During self-restart it waits for the replacement
|
||||
/// session, then acknowledges the handoff so the scheduler can complete.
|
||||
pub async fn transition_after_provider_terminal_async(
|
||||
&mut self,
|
||||
) -> StructuredHumanTurnTransition {
|
||||
let Some(restart) = self.restart.as_mut() else {
|
||||
return StructuredHumanTurnTransition::Complete;
|
||||
};
|
||||
loop {
|
||||
match restart.state() {
|
||||
application::RestartRendezvousState::Idle => {
|
||||
return StructuredHumanTurnTransition::Complete;
|
||||
}
|
||||
application::RestartRendezvousState::Restarting { .. } => {
|
||||
if restart.changed().await.is_err() {
|
||||
return StructuredHumanTurnTransition::Failed(
|
||||
"restart coordination closed".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
application::RestartRendezvousState::Ready { restart_id } => {
|
||||
restart.accept_ready(&restart_id);
|
||||
return StructuredHumanTurnTransition::Resume;
|
||||
}
|
||||
application::RestartRendezvousState::Failed { message, .. } => {
|
||||
return StructuredHumanTurnTransition::Failed(message);
|
||||
}
|
||||
application::RestartRendezvousState::Cancelled { .. } => {
|
||||
return StructuredHumanTurnTransition::Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a self-restart already owns the current concrete
|
||||
/// provider failure. The scheduler publishes `Restarting` synchronously
|
||||
/// before the MCP tool returns, so this check does not inspect provider text.
|
||||
pub fn restart_is_active(&mut self) -> bool {
|
||||
self.restart.as_mut().is_some_and(|restart| {
|
||||
!matches!(restart.state(), application::RestartRendezvousState::Idle)
|
||||
})
|
||||
}
|
||||
|
||||
/// Records proof of liveness for the running turn.
|
||||
pub fn mark_alive(&self) {
|
||||
if self.armed.load(std::sync::atomic::Ordering::Acquire) {
|
||||
@ -2005,6 +2140,8 @@ pub struct BackendCore {
|
||||
/// Mailbox paired with [`Self::turn_watch_input`], used by direct structured
|
||||
/// human turns to retire the synthetic busy/FIFO ticket on terminal outcome.
|
||||
pub input_mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
/// Stable restart control-plane shared by delegated and direct human turns.
|
||||
pub restart_rendezvous: Arc<application::RestartRendezvousCoordinator>,
|
||||
/// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
|
||||
/// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
|
||||
/// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
|
||||
@ -3639,7 +3776,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)
|
||||
.with_restart_rendezvous(Arc::clone(&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.
|
||||
@ -3757,7 +3894,8 @@ impl BackendCore {
|
||||
Arc::clone(&orchestrator_service),
|
||||
)
|
||||
.with_execution_cascade(Arc::clone(&orchestrator_service)
|
||||
as Arc<dyn application::StopAgentExecutionCascade>),
|
||||
as Arc<dyn application::StopAgentExecutionCascade>)
|
||||
.with_restart_rendezvous(Arc::clone(&restart_rendezvous)),
|
||||
);
|
||||
|
||||
// --- Windows (L10) ---
|
||||
@ -3952,6 +4090,7 @@ impl BackendCore {
|
||||
turn_watcher,
|
||||
turn_watch_input: Arc::clone(&input_mediator),
|
||||
input_mailbox: Arc::clone(&mailbox),
|
||||
restart_rendezvous: Arc::clone(&restart_rendezvous),
|
||||
turn_watch_handles: Mutex::new(HashMap::new()),
|
||||
fs_port: Arc::clone(&fs_port),
|
||||
home_dir,
|
||||
|
||||
Reference in New Issue
Block a user