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:
@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user