diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index f3ddb14a..5e20ba71 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -328,6 +328,79 @@ impl Drop for BusyTurnGuard { } } +/// Best-effort live-state cleanup paired with [`BusyTurnGuard`]. +/// +/// `BusyTurnGuard` owns synchronous FIFO/busy cleanup only. Live-state writes are +/// async, so this guard captures the already-materialized updater and spawns a +/// best-effort `Done` transition when the ask future is dropped before an +/// explicit success/error branch can clean the human-facing status row. This is +/// the cross-project cancel/timeout case: the target turn is gone, but the +/// persisted LED must not remain `working` until restart/TTL. +struct LiveStateTurnGuard { + update: Option>, + target: AgentId, + ticket: TicketId, + armed: bool, +} + +impl LiveStateTurnGuard { + fn new(update: Option>, target: AgentId, ticket: TicketId) -> Self { + Self { + update, + target, + ticket, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for LiveStateTurnGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let Some(update) = self.update.clone() else { + return; + }; + let target = self.target; + let ticket = self.ticket; + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(async move { + let outcome = update + .execute(UpdateLiveStateInput { + agent_id: target, + ticket: Some(ticket), + intent: String::new(), + status: domain::live_state::WorkStatus::Done, + progress: None, + last_delegation: Some(ticket), + }) + .await; + if let Err(e) = outcome { + crate::diag!( + "[live-state] drop cleanup failed: target={target} ticket={ticket} err={e}" + ); + } else { + crate::diag!( + "[live-state] drop cleanup marked target done: target={target} ticket={ticket}" + ); + } + }); + } + Err(_) => { + crate::diag!( + "[live-state] drop cleanup skipped without tokio runtime: target={target} ticket={ticket}" + ); + } + } + } +} + /// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP /// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans /// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint). @@ -2122,6 +2195,11 @@ impl OrchestratorService { Self::distill_intent(&task), ) .await; + let live_state_update = self + .live_state + .as_ref() + .and_then(|provider| provider.live_state_for(&project.root)); + let mut live_state_guard = LiveStateTurnGuard::new(live_state_update, agent_id, ticket_id); // Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre // toutes les sorties — `return Err` des bras du select, OU **futur abandonné // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est @@ -2253,6 +2331,7 @@ impl OrchestratorService { } self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; + live_state_guard.disarm(); crate::diag!( "[rendezvous] ask error (structured): target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={} err={err}", @@ -2271,6 +2350,7 @@ impl OrchestratorService { .await?; self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; + live_state_guard.disarm(); crate::diag!( "[rendezvous] ask CEILING (structured, still active): target={target} \ (agent {agent_id}) ticket={ticket_id} after_ms={}", @@ -2290,6 +2370,7 @@ impl OrchestratorService { .await?; self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; + live_state_guard.disarm(); crate::diag!( "[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)", @@ -2325,6 +2406,7 @@ impl OrchestratorService { // résultat (chemin structuré) ⇒ `Done` + `last_delegation` = ticket résolu. self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) .await; + live_state_guard.disarm(); Ok(self.reply_outcome(agent_id, target, result)) } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 4c8191b1..069aab77 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -2865,6 +2865,45 @@ async fn dropped_ask_future_frees_busy_target() { ); } +/// Régression cross-project/workstate — si le client MCP appelant abandonne un +/// `idea_project_ask_agent`, le futur `ask` est droppé. Le garde Busy libère déjà +/// la FIFO ; il faut aussi nettoyer le live-state, sinon l'UI garde une LED +/// `working`/jaune jusqu'au restart ou à la TTL. +#[tokio::test] +async fn dropped_ask_future_marks_live_state_done() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + await_until(|| { + fx.live + .entry_for(aid(1)) + .is_some_and(|entry| entry.status == WorkStatus::Working) + }) + .await; + + ask.abort(); + let _ = ask.await; + + await_until(|| { + fx.live + .entry_for(aid(1)) + .is_some_and(|entry| entry.status == WorkStatus::Done) + }) + .await; + let entry = fx + .live + .entry_for(aid(1)) + .expect("target live entry present"); + assert_eq!( + entry.last_delegation, entry.ticket, + "drop cleanup preserves the ticket as last_delegation" + ); +} + /// Régression (garde RAII) — une **2e délégation** vers une cible dont un 1er `ask` a /// été abandonné est bien livrée : l'agent n'est pas coincé `Busy`, son tour suivant /// démarre et peut être résolu par `idea_reply`.