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:
2026-06-21 10:06:29 +02:00
parent 6051c6f58a
commit 9684b7bbec
7 changed files with 571 additions and 41 deletions

View File

@ -27,13 +27,18 @@ use tokio::sync::oneshot;
use domain::ids::AgentId;
use domain::mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId,
TicketId, TurnResolution,
};
/// One queued request plus the sender that resolves its awaiting [`PendingReply`].
///
/// The one-shot carries a [`TurnResolution`] so the awaiting caller learns **how** the
/// turn ended: a [`TurnResolution::Replied`] (an `idea_reply`) or a
/// [`TurnResolution::ReturnedToPromptNoReply`] (the target returned to its prompt
/// without replying — see [`AgentMailbox::complete_without_reply`]).
struct Slot {
ticket: Ticket,
reply: oneshot::Sender<String>,
reply: oneshot::Sender<TurnResolution>,
}
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
@ -76,7 +81,7 @@ impl InMemoryMailbox {
impl AgentMailbox for InMemoryMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let (tx, rx) = oneshot::channel::<String>();
let (tx, rx) = oneshot::channel::<TurnResolution>();
let ticket_id = ticket.id;
let (depth_before, depth_after) = {
let mut queues = self
@ -129,7 +134,7 @@ impl AgentMailbox for InMemoryMailbox {
);
// A dropped receiver (the awaiting caller timed out and went away) is fine:
// the reply is simply discarded, the head ticket is already retired.
let _ = slot.reply.send(result);
let _ = slot.reply.send(TurnResolution::Replied(result));
Ok(())
}
@ -174,7 +179,7 @@ impl AgentMailbox for InMemoryMailbox {
"[mailbox] resolve_ticket agent={agent} ticket={ticket_id} \
queue_depth_before={depth_before} queue_depth_after={depth_after}"
);
let _ = slot.reply.send(result);
let _ = slot.reply.send(TurnResolution::Replied(result));
Ok(())
}
@ -212,6 +217,59 @@ impl AgentMailbox for InMemoryMailbox {
queue_depth_before={depth_before} queue_depth_after={depth_after} retired={retired}"
);
}
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
// Head-only, idempotent, and fire-and-forget-safe (see the port contract).
// `outcome` records what happened for the diag beacon: "sent" (head retired +
// caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already
// gone — human submit / timed-out caller; head left for cancel/timeout to own),
// or "skip-not-head" (already resolved/cancelled or another ticket in front).
let (outcome, depth_before, depth_after) = {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(queue) = queues.get_mut(&agent) else {
application::diag!(
"[mailbox] complete_without_reply agent={agent} ticket={ticket_id} \
queue_depth_before=0 queue_depth_after=0 outcome=skip-no-queue"
);
return;
};
let before = queue.len();
// Only act on the head, and only if it is *this* ticket (idempotent: a head
// that has since changed must never be retired here).
if queue.front().map(|s| s.ticket.id) != Some(ticket_id) {
application::diag!(
"[mailbox] complete_without_reply agent={agent} ticket={ticket_id} \
queue_depth_before={before} queue_depth_after={before} outcome=skip-not-head"
);
return;
}
// Preserve fire-and-forget: if the awaiting receiver already went away (a
// human submit that is never awaited, or a caller that already timed out),
// do NOT pop — the existing cancel_head/timeout path owns that head.
if queue.front().is_some_and(|s| s.reply.is_closed()) {
application::diag!(
"[mailbox] complete_without_reply agent={agent} ticket={ticket_id} \
queue_depth_before={before} queue_depth_after={before} outcome=skip-closed"
);
return;
}
let slot = queue.pop_front().expect("head just matched is present");
let after = queue.len();
if queue.is_empty() {
queues.remove(&agent);
}
// Wake the awaiting caller with the "returned to prompt, no reply" outcome.
let _ = slot.reply.send(TurnResolution::ReturnedToPromptNoReply);
("sent", before, after)
};
application::diag!(
"[mailbox] complete_without_reply agent={agent} ticket={ticket_id} \
queue_depth_before={depth_before} queue_depth_after={depth_after} outcome={outcome}"
);
}
}
impl AgentQueueSnapshot for InMemoryMailbox {
@ -264,7 +322,7 @@ mod tests {
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
let reply = pending.await.expect("reply received");
assert_eq!(reply, "done X");
assert_eq!(reply, TurnResolution::Replied("done X".to_owned()));
assert_eq!(mb.pending(&a), 0, "queue drained after resolve");
}
@ -282,14 +340,14 @@ mod tests {
// First resolve goes to the FIRST (head) ticket.
mb.resolve(a, "r1".to_owned()).unwrap();
assert_eq!(p1.await.unwrap(), "r1");
assert_eq!(p1.await.unwrap(), TurnResolution::Replied("r1".to_owned()));
// Now the second is at the head.
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
);
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
assert_eq!(p2.await.unwrap(), TurnResolution::Replied("r2".to_owned()));
}
#[tokio::test]
@ -304,7 +362,7 @@ mod tests {
mb.resolve(b, "rb".to_owned()).unwrap();
assert_eq!(mb.pending(&a), 1, "A's queue is independent of B's");
mb.resolve(a, "ra".to_owned()).unwrap();
assert_eq!(pa.await.unwrap(), "ra");
assert_eq!(pa.await.unwrap(), TurnResolution::Replied("ra".to_owned()));
}
#[test]
@ -337,7 +395,78 @@ mod tests {
// The next ticket is now resolvable normally.
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
assert_eq!(p2.await.unwrap(), TurnResolution::Replied("r2".to_owned()));
}
#[tokio::test]
async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let pending = mb.enqueue(a, ticket(10, "awaited"));
// The target returned to its prompt without idea_reply ⇒ the awaiting caller
// is woken with ReturnedToPromptNoReply (not a String), and the head is retired.
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(
pending.await.unwrap(),
TurnResolution::ReturnedToPromptNoReply
);
assert_eq!(mb.pending(&a), 0, "head retired after completion");
}
#[tokio::test]
async fn complete_without_reply_is_noop_when_receiver_already_gone() {
let mb = InMemoryMailbox::new();
let a = agent(1);
// Human fire-and-forget: the PendingReply is dropped immediately (not awaited).
drop(mb.enqueue(a, ticket(10, "human submit")));
// The receiver is closed ⇒ completion must SKIP without retiring the head, so
// the existing cancel/timeout path still owns it (no behavioural change).
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(mb.pending(&a), 1, "fire-and-forget head preserved");
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
}
#[tokio::test]
async fn complete_without_reply_is_noop_when_not_head_and_idempotent() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "behind"));
// Targeting a non-head ticket ⇒ no-op.
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(11)));
assert_eq!(mb.pending(&a), 2, "non-head completion is a no-op");
// Complete the head, then a second call for the same ticket is idempotent.
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(p1.await.unwrap(), TurnResolution::ReturnedToPromptNoReply);
assert_eq!(mb.pending(&a), 1, "only the head was retired");
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(mb.pending(&a), 1, "idempotent: already retired");
}
/// `idea_reply` wins a race against a pending completion: once the head is resolved
/// with a `Replied`, a later `complete_without_reply` for the same ticket is a no-op.
#[tokio::test]
async fn reply_before_completion_wins_then_completion_is_noop() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let pending = mb.enqueue(a, ticket(10, "raced"));
mb.resolve(a, "real answer".to_owned()).unwrap();
// The (late) completion finds the head already gone ⇒ no-op.
mb.complete_without_reply(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(
pending.await.unwrap(),
TurnResolution::Replied("real answer".to_owned()),
"the idea_reply result wins the race"
);
}
#[test]