fix(orchestrator): garde RAII contre une cible coincée Busy après une délégation interrompue

Une délégation `idea_ask_agent` interrompue/annulée côté demandeur (futur
`ask_agent` *dropped*) laissait la cible en `Busy` à vie : sur un drop, aucune
branche du `select!` ne s'exécute, donc `mark_idle` n'était jamais appelé. Toute
délégation suivante restait en file derrière ce tour fantôme et n'était jamais
livrée au PTY (symptôme : « DevFrontend/QA ne répondent plus », pont MCP pourtant
ESTAB).

Ajoute un garde RAII `BusyTurnGuard` posé juste après l'enqueue sur les deux
chemins (`ask` PTY et `ask_structured`) : son `Drop` fait `cancel_head` +
`mark_idle` quoi qu'il arrive (erreur, timeout, drop), sauf `disarm()` sur la
branche succès. Indispensable d'être un garde et non un `mark_idle` dans les
branches : le cas réel est un futur droppé. Retire les `cancel_head` redondants
des branches erreur/timeout (positionnel/idempotent).

`sweep_stalled` reste advisory (n'appelle jamais `mark_idle`) — non concerné.

Tests: dropped_ask_future_frees_busy_target, second_delegation_delivered_after_dropped_ask,
cancelled_ask_marks_target_idle + 2 tests du garde. cargo test --workspace vert (80 suites).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 19:15:01 +02:00
parent aa2f67ae89
commit 46492506e1
2 changed files with 408 additions and 46 deletions

View File

@ -1255,6 +1255,124 @@ async fn idea_reply_marks_emitter_idle() {
);
}
/// Régression (garde RAII de fin de tour) — LE test décisif : un `ask_agent` dont le
/// **futur est abandonné (drop)** alors qu'il attendait la réponse doit laisser la cible
/// `Idle`, **pas** `Busy` à vie. Avant le fix, aucune branche du `match`/`select!` ne
/// s'exécutait sur un drop ⇒ l'agent restait `Busy` et toute délégation suivante était
/// mise en file derrière ce tour fantôme sans jamais être livrée.
#[tokio::test]
async fn dropped_ask_future_frees_busy_target() {
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 });
// Le tour a démarré : la cible est Busy, un ticket est en file.
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
assert!(
fx.mediator.busy_state(aid(1)).is_busy(),
"cible Busy pendant le tour délégué"
);
// Le demandeur interrompt son appel ⇒ le futur `ask_agent` est dropped.
ask.abort();
let _ = ask.await; // récolte la JoinError(Cancelled), ignorée.
// Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO.
await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await;
assert_eq!(
fx.mediator.busy_state(aid(1)),
AgentBusyState::Idle,
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)"
);
assert_eq!(
fx.mailbox.pending(&aid(1)),
0,
"ticket fantôme retiré de la FIFO au Drop du garde"
);
}
/// 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`.
#[tokio::test]
async fn second_delegation_delivered_after_dropped_ask() {
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));
// 1er ask : démarre puis est abandonné (drop).
let svc = Arc::clone(&fx.service);
let ask1 = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
ask1.abort();
let _ = ask1.await;
await_until(|| fx.mailbox.pending(&aid(1)) == 0).await;
// 2e ask vers la MÊME cible : doit démarrer un nouveau tour (pas coincé derrière un
// tour fantôme) et se laisser résoudre.
let svc2 = Arc::clone(&fx.service);
let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
assert!(
fx.mediator.busy_state(aid(1)).is_busy(),
"le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée"
);
fx.service
.dispatch(&project(), reply_cmd(aid(1), "réponse au 2e tour"))
.await
.expect("reply ok");
let out = timeout(TEST_GUARD, ask2)
.await
.expect("le 2e ask se termine")
.expect("join ok")
.expect("ask ok");
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
assert_eq!(
fx.mediator.busy_state(aid(1)),
AgentBusyState::Idle,
"cible Idle après résolution du 2e tour"
);
}
/// Régression (garde RAII) — la **branche erreur/timeout** (canal fermé) ramène aussi la
/// cible `Idle`. Avant le fix, ces branches faisaient `cancel_head` mais jamais
/// `mark_idle` ⇒ l'agent restait `Busy`. On déclenche la fermeture du canal en retirant
/// le ticket de tête (même nettoyage que le timeout de tour).
#[tokio::test]
async fn cancelled_ask_marks_target_idle() {
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;
assert!(fx.mediator.busy_state(aid(1)).is_busy());
// Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en
// erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
let t = fx.mailbox.ticket_ids(&aid(1))[0];
fx.mailbox.cancel_head(aid(1), t);
let err = timeout(TEST_GUARD, ask)
.await
.expect("ask retourne vite sur canal fermé")
.expect("join ok")
.unwrap_err();
assert!(
matches!(err.code(), "PROCESS" | "INVALID"),
"ask sur canal fermé ⇒ erreur typée : {err:?}"
);
// Le garde a ramené la cible Idle (la FIFO peut avancer).
assert_eq!(
fx.mediator.busy_state(aid(1)),
AgentBusyState::Idle,
"branche erreur ⇒ cible Idle (garde RAII)"
);
}
/// A dead target is launched in the background (PTY) before the task is written.
#[tokio::test]
async fn ask_dead_target_launches_pty_then_writes_and_replies() {