fix(orchestrator): LiveStateTurnGuard ramène la LED live-state à Done au drop du futur ask cross-project — #281 (RAII armé après publication Working, désarmé sur sorties explicites mark_target_done_best_effort, Drop spawn best-effort UpdateLiveState Done avec last_delegation=ticket; test régression dropped_ask_future_marks_live_state_done; QA verte: cargo fmt, orchestrator_service 78/78, npm build frontend, AppImage rebuildée 2026-09-08 13:52; réserve documentée: échec préexistant launch_conventionfile_injects_assigned_skills_in_order hors périmètre)

This commit is contained in:
2026-09-08 14:54:09 +02:00
parent 3ff62082eb
commit 566e3c3732
2 changed files with 121 additions and 0 deletions

View File

@ -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<Arc<UpdateLiveState>>,
target: AgentId,
ticket: TicketId,
armed: bool,
}
impl LiveStateTurnGuard {
fn new(update: Option<Arc<UpdateLiveState>>, 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))
}