fix(delegation): résout idea_ask_agent quand la cible revient au prompt sans idea_reply
Introduit le seam TurnResolution : sur prompt-ready, après un délai de grâce, la délégation en attente est résolue de façon typée plutôt que de rester bloquée indéfiniment (jusquà 24h). Couvre domain (mailbox), infrastructure (mailbox/input) et application (orchestrator/error), avec tests étendus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -22,6 +22,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::AgentId;
|
||||
@ -62,8 +63,24 @@ struct BusyTracker {
|
||||
/// présent ; `None` ⇒ toute livraison passe par l'événement (médiateur sans PTY,
|
||||
/// utilisé par les tests événementiels — comportement historique).
|
||||
headless_sink: Option<HeadlessSink>,
|
||||
/// Mailbox sous-jacent, pour armer la **fenêtre de grâce** au `prompt_ready` sans
|
||||
/// délégation différée : si la cible revient à son prompt sans `idea_reply`, on
|
||||
/// réveille l'appelant (via [`AgentMailbox::complete_without_reply`]) au lieu de le
|
||||
/// laisser bloqué jusqu'au timeout long. `None` ⇒ pas de grâce (repli sur le filet
|
||||
/// timeout — comportement historique pour les médiateurs sans mailbox câblé).
|
||||
mailbox: Option<Arc<InMemoryMailbox>>,
|
||||
/// Durée de la **fenêtre de grâce** (G) accordée à la cible après son retour au
|
||||
/// prompt pour qu'un `idea_reply` arrive avant qu'on complète le tour « sans
|
||||
/// réponse ». Prod = [`PROMPT_READY_GRACE`] (2 s) ; un test peut la raccourcir via
|
||||
/// [`MediatedInbox::with_grace`].
|
||||
grace: Duration,
|
||||
}
|
||||
|
||||
/// Fenêtre de grâce (G) par défaut entre le retour au prompt de la cible et la
|
||||
/// complétion « sans réponse » du tour : laisse à un `idea_reply` tardif le temps
|
||||
/// d'arriver d'abord. Décision produit (Main) : **2 secondes**.
|
||||
const PROMPT_READY_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Payload d'une [`DomainEvent::DelegationReady`] **différée** le temps qu'un agent
|
||||
/// lancé à froid atteigne son prompt (fix race cold-launch). Reconstruite à l'identique
|
||||
/// quand le prompt-ready watcher déclenche, de sorte que le premier tour soit livré au
|
||||
@ -124,6 +141,8 @@ impl BusyTracker {
|
||||
deferred: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
headless_sink: None,
|
||||
mailbox: None,
|
||||
grace: PROMPT_READY_GRACE,
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,16 +355,34 @@ impl BusyTracker {
|
||||
self.publish_deferred(agent, d);
|
||||
}
|
||||
None => {
|
||||
// Pas de tour différé : le prompt-ready ne fait que **libérer** le busy
|
||||
// state / la FIFO (mark_idle). Il ne porte AUCUNE réponse pour réveiller
|
||||
// un appelant en attente — d'où `no_reply_payload`. C'est exactement le
|
||||
// scénario où une cible revient prompt-ready sans `idea_reply` : l'ask
|
||||
// ne se résout pas par ce chemin, seulement par l'`idea_reply` ou le
|
||||
// timeout côté appelant.
|
||||
// Pas de tour différé : la cible est revenue à son prompt sans
|
||||
// `idea_reply`. On capture le ticket actif AVANT `mark_idle` (qui efface
|
||||
// l'état Busy), puis on libère le busy state / la FIFO comme avant. Le
|
||||
// prompt-ready ne porte AUCUNE réponse (`no_reply_payload`) : pour ne pas
|
||||
// laisser l'appelant bloqué jusqu'au timeout long, on **arme une fenêtre
|
||||
// de grâce** G — si aucun `idea_reply` n'a corrélé le ticket d'ici là, on
|
||||
// complète le tour « sans réponse » (réveille l'appelant avec une erreur
|
||||
// typée). Un `idea_reply` arrivé pendant G gagne (la complétion devient
|
||||
// un no-op, tête déjà retirée).
|
||||
let active = self.lock().get(&agent).and_then(AgentBusyState::ticket);
|
||||
application::diag!(
|
||||
"[input-mediator] prompt_ready agent={agent} no_reply_payload -> mark_idle"
|
||||
"[input-mediator] prompt_ready agent={agent} no_reply_payload -> mark_idle + grace"
|
||||
);
|
||||
self.mark_idle(agent);
|
||||
if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) {
|
||||
let grace = self.grace;
|
||||
application::diag!(
|
||||
"[input-mediator] prompt_ready arming grace agent={agent} ticket={ticket} grace_ms={}",
|
||||
grace.as_millis(),
|
||||
);
|
||||
// Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) :
|
||||
// attend G puis tente la complétion « sans réponse ». `complete_without_reply`
|
||||
// est head-only/idempotent et préserve le fire-and-forget humain.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(grace);
|
||||
mailbox.complete_without_reply(agent, ticket);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -452,6 +489,9 @@ pub struct MediatedInbox {
|
||||
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
|
||||
/// watcher thread un-arms its own entry on exit.
|
||||
watched: Arc<Mutex<HashSet<AgentId>>>,
|
||||
/// Fenêtre de grâce (G) propagée au [`BusyTracker`] à chaque (re)construction.
|
||||
/// Prod = [`PROMPT_READY_GRACE`] (2 s) ; surchargée par [`Self::with_grace`] en test.
|
||||
grace: Duration,
|
||||
}
|
||||
|
||||
impl MediatedInbox {
|
||||
@ -461,9 +501,17 @@ impl MediatedInbox {
|
||||
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
|
||||
let handles = Arc::new(Mutex::new(HashMap::new()));
|
||||
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
||||
let tracker = Self::build_tracker(
|
||||
None,
|
||||
None,
|
||||
&handles,
|
||||
&front_owned,
|
||||
&mailbox,
|
||||
PROMPT_READY_GRACE,
|
||||
);
|
||||
Self {
|
||||
mailbox,
|
||||
tracker: Self::build_tracker(None, None, &handles, &front_owned),
|
||||
tracker,
|
||||
clock,
|
||||
pty: None,
|
||||
handles,
|
||||
@ -471,6 +519,7 @@ impl MediatedInbox {
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
stall: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
grace: PROMPT_READY_GRACE,
|
||||
}
|
||||
}
|
||||
|
||||
@ -481,8 +530,14 @@ impl MediatedInbox {
|
||||
pty: Option<&Arc<dyn PtyPort>>,
|
||||
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
|
||||
front_owned: &Arc<Mutex<HashSet<AgentId>>>,
|
||||
mailbox: &Arc<InMemoryMailbox>,
|
||||
grace: Duration,
|
||||
) -> Arc<BusyTracker> {
|
||||
let mut tracker = BusyTracker::new(events);
|
||||
// Le tracker porte le mailbox + la grâce pour pouvoir compléter « sans réponse »
|
||||
// un tour dont la cible est revenue au prompt sans `idea_reply` (cf. prompt_ready).
|
||||
tracker.mailbox = Some(Arc::clone(mailbox));
|
||||
tracker.grace = grace;
|
||||
if let Some(pty) = pty {
|
||||
tracker.headless_sink = Some(Self::make_headless_sink(
|
||||
Arc::clone(pty),
|
||||
@ -601,6 +656,27 @@ impl MediatedInbox {
|
||||
self.pty.as_ref(),
|
||||
&self.handles,
|
||||
&self.front_owned,
|
||||
&self.mailbox,
|
||||
self.grace,
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// **Test seam**: shrinks the prompt-ready **grace window** (G, see
|
||||
/// [`PROMPT_READY_GRACE`]) so a no-reply test need not wait the full 2 s. Rebuilds
|
||||
/// the tracker with the new grace; production code keeps the default. Call at
|
||||
/// construction time (before any turn) — it resets the freshly built tracker.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn with_grace(mut self, grace: Duration) -> Self {
|
||||
self.grace = grace;
|
||||
self.tracker = Self::build_tracker(
|
||||
self.tracker.events.clone(),
|
||||
self.pty.as_ref(),
|
||||
&self.handles,
|
||||
&self.front_owned,
|
||||
&self.mailbox,
|
||||
grace,
|
||||
);
|
||||
self
|
||||
}
|
||||
@ -617,9 +693,17 @@ impl MediatedInbox {
|
||||
let handles = Arc::new(Mutex::new(HashMap::new()));
|
||||
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
||||
let pty_opt = Some(pty);
|
||||
let tracker = Self::build_tracker(
|
||||
None,
|
||||
pty_opt.as_ref(),
|
||||
&handles,
|
||||
&front_owned,
|
||||
&mailbox,
|
||||
PROMPT_READY_GRACE,
|
||||
);
|
||||
Self {
|
||||
mailbox,
|
||||
tracker: Self::build_tracker(None, pty_opt.as_ref(), &handles, &front_owned),
|
||||
tracker,
|
||||
clock,
|
||||
pty: pty_opt,
|
||||
handles,
|
||||
@ -627,6 +711,7 @@ impl MediatedInbox {
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
stall: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
grace: PROMPT_READY_GRACE,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1309,7 +1394,10 @@ mod tests {
|
||||
let pending = inbox.enqueue(a, ticket(10, "do X"));
|
||||
// Resolve through the shared mailbox (the orchestrator's path).
|
||||
inbox.mailbox().resolve(a, "done".to_owned()).unwrap();
|
||||
assert_eq!(pending.await.unwrap(), "done");
|
||||
assert_eq!(
|
||||
pending.await.unwrap(),
|
||||
domain::mailbox::TurnResolution::Replied("done".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1367,7 +1455,10 @@ mod tests {
|
||||
assert_eq!(inbox.mailbox().pending(&a), 1);
|
||||
// Nothing answered the caller via preempt.
|
||||
inbox.mailbox().resolve(a, "real".to_owned()).unwrap();
|
||||
assert_eq!(pending.await.unwrap(), "real");
|
||||
assert_eq!(
|
||||
pending.await.unwrap(),
|
||||
domain::mailbox::TurnResolution::Replied("real".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1490,6 +1581,155 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a PTY-backed inbox with a **short grace** so the no-reply path is
|
||||
/// observable in milliseconds (prod grace is 2 s).
|
||||
fn inbox_with_grace(pty: Arc<FakePty>, grace: Duration) -> MediatedInbox {
|
||||
MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
pty as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_grace(grace)
|
||||
}
|
||||
|
||||
// --- fix: prompt-ready sans idea_reply ⇒ réveil de l'appelant après G ----------
|
||||
|
||||
/// La cible revient à son prompt SANS `idea_reply` : après la fenêtre de grâce,
|
||||
/// l'appelant en attente est réveillé avec `ReturnedToPromptNoReply` — pas de
|
||||
/// blocage jusqu'au timeout long (le bug corrigé).
|
||||
#[tokio::test]
|
||||
async fn prompt_ready_without_reply_wakes_caller_after_grace() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(50);
|
||||
pty.seed(&h, vec![b"done\n> ".to_vec()]);
|
||||
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40));
|
||||
|
||||
let pending = inbox.enqueue(a, ticket(10, "task"));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
|
||||
// Bounded await: the grace (40 ms) must resolve the caller well under this.
|
||||
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
||||
.await
|
||||
.expect("must NOT hang until the long timeout")
|
||||
.expect("a turn outcome, not a closed channel");
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
||||
);
|
||||
assert_eq!(
|
||||
inbox.mailbox().pending(&a),
|
||||
0,
|
||||
"head retired by the no-reply completion"
|
||||
);
|
||||
}
|
||||
|
||||
/// `idea_reply` arrivé AVANT le prompt-ready gagne : l'appelant reçoit le `Replied`
|
||||
/// et la complétion de grâce (tête déjà retirée) est un no-op.
|
||||
#[tokio::test]
|
||||
async fn reply_before_prompt_ready_wins() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(51);
|
||||
pty.seed(&h, vec![b"done\n> ".to_vec()]);
|
||||
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40));
|
||||
|
||||
let pending = inbox.enqueue(a, ticket(10, "task"));
|
||||
// idea_reply resolves the head BEFORE the watcher fires prompt-ready.
|
||||
inbox.mailbox().resolve(a, "answer".to_owned()).unwrap();
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
|
||||
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("outcome");
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::Replied("answer".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// `idea_reply` arrivé APRÈS le prompt-ready mais DANS la fenêtre de grâce gagne :
|
||||
/// avec une grâce longue, on résout via la mailbox avant son expiration.
|
||||
#[tokio::test]
|
||||
async fn reply_within_grace_after_prompt_ready_wins() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(52);
|
||||
pty.seed(&h, vec![b"done\n> ".to_vec()]);
|
||||
// Long grace ⇒ the reply lands well within it.
|
||||
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_secs(30));
|
||||
|
||||
let pending = inbox.enqueue(a, ticket(10, "task"));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
// Let the watcher fire prompt-ready (arms the grace), then reply within it.
|
||||
assert!(
|
||||
wait_until(|| !inbox.busy_state(a).is_busy()),
|
||||
"prompt-ready marks idle (grace armed)"
|
||||
);
|
||||
inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap();
|
||||
|
||||
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("outcome");
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::Replied("in-time".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// `idea_reply` arrivé APRÈS l'expiration de la grâce devient *unmatched* (la tête a
|
||||
/// été retirée par la complétion) — typé, idempotent, jamais un panic.
|
||||
#[tokio::test]
|
||||
async fn reply_after_grace_is_unmatched() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(53);
|
||||
pty.seed(&h, vec![b"done\n> ".to_vec()]);
|
||||
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(30));
|
||||
|
||||
let pending = inbox.enqueue(a, ticket(10, "task"));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
// The caller is woken by the grace completion (head retired).
|
||||
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("outcome");
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
||||
);
|
||||
|
||||
// A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest.
|
||||
assert!(
|
||||
inbox.mailbox().resolve(a, "too late".to_owned()).is_err(),
|
||||
"late reply is unmatched, not a panic"
|
||||
);
|
||||
}
|
||||
|
||||
/// Submit humain *fire-and-forget* (PendingReply non attendu) : la grâce ne doit PAS
|
||||
/// retirer la tête — `complete_without_reply` skip quand le receiver est fermé.
|
||||
#[test]
|
||||
fn fire_and_forget_head_is_preserved_through_grace() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(54);
|
||||
pty.seed(&h, vec![b"done\n> ".to_vec()]);
|
||||
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(20));
|
||||
|
||||
// Human submit: the reply handle is dropped immediately (not awaited).
|
||||
drop(inbox.enqueue(a, ticket(10, "human submit")));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
// Give the grace thread time to run; the head must survive (skip-closed).
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
assert_eq!(
|
||||
inbox.mailbox().pending(&a),
|
||||
1,
|
||||
"fire-and-forget head preserved (grace is a no-op on a closed receiver)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() {
|
||||
let pty = Arc::new(FakePty::new());
|
||||
|
||||
Reference in New Issue
Block a user