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

@ -191,6 +191,28 @@ impl Ticket {
}
}
/// The outcome of a delegated turn, delivered to the awaiting [`PendingReply`].
///
/// A turn ends in **exactly one** of two ways (besides the channel closing, which is
/// [`MailboxError::Cancelled`]):
/// - [`TurnResolution::Replied`] — the target rendered a result via `idea_reply`
/// ([`AgentMailbox::resolve`] / [`AgentMailbox::resolve_ticket`]). The normal
/// success: the caller receives the answer inline.
/// - [`TurnResolution::ReturnedToPromptNoReply`] — the target **finished its turn and
/// returned to its prompt without calling `idea_reply`**. The rendezvous can produce
/// no payload, so instead of leaving the caller blocked until the long safety
/// timeout, the mailbox wakes it with this variant
/// ([`AgentMailbox::complete_without_reply`]) so the application layer can surface a
/// clear, retryable error promptly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnResolution {
/// The target rendered a result via `idea_reply`. Carries the answer.
Replied(String),
/// The target returned to its prompt (turn ended) **without** an `idea_reply`.
/// No payload exists; the caller must surface a typed, retryable error.
ReturnedToPromptNoReply,
}
/// Errors raised by the [`AgentMailbox`] port.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MailboxError {
@ -206,28 +228,32 @@ pub enum MailboxError {
Cancelled,
}
/// A handle the caller awaits to receive a target agent's reply.
/// A handle the caller awaits to receive a target agent's turn outcome.
///
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
/// free of any concrete channel type, and the await point lives in the application
/// layer's `ask_agent`.
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields a [`TurnResolution`] (a
/// [`TurnResolution::Replied`] result fed by a later [`AgentMailbox::resolve`], or a
/// [`TurnResolution::ReturnedToPromptNoReply`] fed by
/// [`AgentMailbox::complete_without_reply`] when the target returns to its prompt
/// without replying), or [`MailboxError::Cancelled`] if the reply channel closes
/// first. The future is **opaque** (the adapter builds it from its one-shot
/// receiver): the domain stays free of any concrete channel type, and the await point
/// lives in the application layer's `ask_agent`.
pub struct PendingReply {
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
}
impl PendingReply {
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
#[must_use]
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
pub fn new(
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
) -> Self {
Self { inner }
}
}
impl Future for PendingReply {
type Output = Result<String, MailboxError>;
type Output = Result<TurnResolution, MailboxError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.inner.as_mut().poll(cx)
@ -301,6 +327,28 @@ pub trait AgentMailbox: Send + Sync {
/// A no-op when the head is a different ticket (the timed-out one was already
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
/// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`].
///
/// Called when the target **returned to its prompt** (its turn ended) without
/// rendering an `idea_reply`: the rendezvous can produce no payload, so rather than
/// leaving the caller blocked until its long safety timeout, the head ticket is
/// resolved with the "no reply" outcome so the caller errors out promptly.
///
/// Contract for implementations:
/// - **head-only & idempotent**: act only if `ticket_id` is currently at the head
/// (a no-op once it has been resolved/cancelled, or if another ticket is in
/// front) — exactly like [`AgentMailbox::cancel_head`];
/// - **preserve fire-and-forget**: if the awaiting receiver already went away
/// (a human submit that is not awaited, or a caller that already timed out),
/// skip without retiring the head — the existing cancel/timeout paths own it.
///
/// The default is a no-op: a mailbox that cannot wake a pending caller early simply
/// falls back to the caller's timeout net (no behavioural change for it).
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
let _ = (agent, ticket_id);
}
}
#[cfg(test)]