|
|
|
|
@ -17,6 +17,8 @@
|
|
|
|
|
//! at the composition root (M3), exactly like the watcher — no application logic is
|
|
|
|
|
//! duplicated here.
|
|
|
|
|
|
|
|
|
|
use std::future::Future;
|
|
|
|
|
use std::pin::Pin;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
@ -34,29 +36,51 @@ use super::tools::{self, ToolMapError};
|
|
|
|
|
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
|
|
|
|
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
|
|
|
|
|
|
/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous.
|
|
|
|
|
/// **Inactivity window (silence budget)** for the synchronous `idea_ask_agent`
|
|
|
|
|
/// rendezvous — the size of one *no-progress* probe window, NOT an absolute cap.
|
|
|
|
|
///
|
|
|
|
|
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
|
|
|
|
|
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
|
|
|
|
|
/// layer). The application layer already bounds the rendezvous itself, but this
|
|
|
|
|
/// adapter adds its own **finite** outer bound so a wedged target can never park a
|
|
|
|
|
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
|
|
|
|
|
/// instead of hanging.
|
|
|
|
|
/// layer). Rather than a flat outer timeout (which wrongly fired on a single long
|
|
|
|
|
/// delegated turn that never returns to its prompt nor emits `turn_duration`), the
|
|
|
|
|
/// adapter runs an **inactivity watchdog**: it races the dispatch against this window
|
|
|
|
|
/// and, on each window expiry, probes whether the target made progress (its transcript
|
|
|
|
|
/// grew). Progress ⇒ the window is re-armed (extension) up to the absolute
|
|
|
|
|
/// [`ASK_RENDEZVOUS_CEILING`]; genuine silence ⇒ the no-reply error fires. 600 s by
|
|
|
|
|
/// default, overridable per project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other
|
|
|
|
|
/// tool (`idea_reply`, `idea_list_agents`, …) is left untouched — they don't rendezvous.
|
|
|
|
|
///
|
|
|
|
|
/// **Plancher universel FINI (backstop no-reply).** The real turn-end signal now exists
|
|
|
|
|
/// (the transcript `turn_duration` watcher), but only Claude emits it; this outer bound
|
|
|
|
|
/// stays the last resort for any silent target that never emits it (Codex & co) or that
|
|
|
|
|
/// wedges. It must therefore NEVER be (quasi-)infinite. 600 s by default, overridable per
|
|
|
|
|
/// project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other tool (`idea_reply`,
|
|
|
|
|
/// `idea_list_agents`, …) is left untouched — they don't rendezvous.
|
|
|
|
|
/// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests
|
|
|
|
|
/// that shrink the bound), this degrades to the previous *flat* timeout: the first
|
|
|
|
|
/// window expiry with no probe is treated as a no-reply expiry — zero regression.
|
|
|
|
|
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600);
|
|
|
|
|
|
|
|
|
|
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional
|
|
|
|
|
/// configured override in **milliseconds** (project/profile level), mirroring the
|
|
|
|
|
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound,
|
|
|
|
|
/// `None`/absent ⇒ the finite [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is
|
|
|
|
|
/// treated as "no override" so a misconfigured zero can never collapse the safety net to
|
|
|
|
|
/// an instant expiry. Pure and unit-testable without wiring.
|
|
|
|
|
/// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending
|
|
|
|
|
/// inactivity window can never park a `tools/call` forever even against a target that
|
|
|
|
|
/// stays *busy* indefinitely. Reaching it while the target is still actively working
|
|
|
|
|
/// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a
|
|
|
|
|
/// blind-retry suggestion). Generous by default (4 h), overridable per project via
|
|
|
|
|
/// `IDEA_ASK_RENDEZVOUS_CEILING_MS`.
|
|
|
|
|
const ASK_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60);
|
|
|
|
|
|
|
|
|
|
/// A monotonic **activity probe** of a target agent, keyed by its display name.
|
|
|
|
|
///
|
|
|
|
|
/// Returns an opaque, monotonically non-decreasing **activity token** (in practice the
|
|
|
|
|
/// cumulative byte size of the target's transcript `.jsonl` files): a strictly larger
|
|
|
|
|
/// token between two probes means the target made progress (it is alive and working,
|
|
|
|
|
/// even within a single long turn that never emits `turn_duration`). `None` ⇒ the
|
|
|
|
|
/// target/transcript could not be resolved (cold start, unknown name) — treated as "no
|
|
|
|
|
/// observable progress" so a truly stuck call still expires. Async because resolving the
|
|
|
|
|
/// token reads the filesystem; built at the composition root (the only layer that knows
|
|
|
|
|
/// the name→run-dir mapping), keeping this adapter free of `AgentId`.
|
|
|
|
|
pub type AskActivityProbe =
|
|
|
|
|
Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = Option<u64>> + Send>> + Send + Sync>;
|
|
|
|
|
|
|
|
|
|
/// Resolves the effective `idea_ask_agent` **inactivity window** from an optional
|
|
|
|
|
/// configured override in **milliseconds**: `Some(ms)` ⇒ that window, `None`/absent ⇒
|
|
|
|
|
/// the [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is treated as "no override"
|
|
|
|
|
/// so a misconfigured zero can never collapse the window to an instant expiry. Pure and
|
|
|
|
|
/// unit-testable without wiring.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
|
|
|
|
|
match override_ms {
|
|
|
|
|
@ -65,6 +89,19 @@ pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolves the effective `idea_ask_agent` **absolute ceiling** from an optional
|
|
|
|
|
/// configured override in **milliseconds**: `Some(ms)` ⇒ that ceiling, `None`/absent ⇒
|
|
|
|
|
/// the [`ASK_RENDEZVOUS_CEILING`] default (4 h). `Some(0)` is treated as "no override".
|
|
|
|
|
/// Pure and unit-testable without wiring.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn resolve_ask_rendezvous_ceiling(override_ms: Option<u32>) -> Duration {
|
|
|
|
|
match override_ms {
|
|
|
|
|
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
|
|
|
|
|
_ => ASK_RENDEZVOUS_CEILING,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
|
|
|
|
///
|
|
|
|
|
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
|
|
|
|
@ -93,11 +130,21 @@ pub struct McpServer {
|
|
|
|
|
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
|
|
|
|
/// composition root parse le `requester`. `None` ⇒ no-op.
|
|
|
|
|
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
|
|
|
|
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see
|
|
|
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a
|
|
|
|
|
/// few milliseconds without polluting the public API; production code always uses
|
|
|
|
|
/// the default.
|
|
|
|
|
/// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see
|
|
|
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress.
|
|
|
|
|
/// Carried per instance so tests can shrink it to a few milliseconds without
|
|
|
|
|
/// polluting the public API; production code uses the resolved default.
|
|
|
|
|
ask_rendezvous_timeout: Duration,
|
|
|
|
|
/// **Absolute ceiling** of the `idea_ask_agent` rendezvous (see
|
|
|
|
|
/// [`ASK_RENDEZVOUS_CEILING`]): the extending inactivity window never parks a call
|
|
|
|
|
/// past this. Per instance so tests can shrink it; production uses the resolved
|
|
|
|
|
/// default (4 h).
|
|
|
|
|
ask_rendezvous_ceiling: Duration,
|
|
|
|
|
/// Optional monotonic **activity probe** of the target (see [`AskActivityProbe`]),
|
|
|
|
|
/// injected by the composition root. Drives window extension: progress between two
|
|
|
|
|
/// probes re-arms the window. `None` ⇒ no observability ⇒ the rendezvous degrades to
|
|
|
|
|
/// a single flat window (legacy behaviour, zero regression).
|
|
|
|
|
activity_probe: Option<AskActivityProbe>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl McpServer {
|
|
|
|
|
@ -113,6 +160,8 @@ impl McpServer {
|
|
|
|
|
requester: String::new(),
|
|
|
|
|
ready_sink: None,
|
|
|
|
|
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
|
|
|
|
|
ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING,
|
|
|
|
|
activity_probe: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -154,24 +203,50 @@ impl McpServer {
|
|
|
|
|
requester: requester.into(),
|
|
|
|
|
ready_sink: self.ready_sink.clone(),
|
|
|
|
|
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
|
|
|
|
|
ask_rendezvous_ceiling: self.ask_rendezvous_ceiling,
|
|
|
|
|
activity_probe: self.activity_probe.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Overrides the `idea_ask_agent` rendezvous safety-net bound (default
|
|
|
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`]).
|
|
|
|
|
/// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default
|
|
|
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s).
|
|
|
|
|
///
|
|
|
|
|
/// A genuine **configuration knob** (project/profile level, modelled on the
|
|
|
|
|
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
|
|
|
|
|
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
|
|
|
|
|
/// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero
|
|
|
|
|
/// regression on legitimately long delegated turns). Tests also use it to shrink the
|
|
|
|
|
/// bound to milliseconds.
|
|
|
|
|
/// it here. This is the size of one *no-progress* window: as long as the activity
|
|
|
|
|
/// probe sees the target advancing, the window is re-armed up to the absolute
|
|
|
|
|
/// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it
|
|
|
|
|
/// to shrink the window to milliseconds.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
|
|
|
|
self.ask_rendezvous_timeout = timeout;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Overrides the `idea_ask_agent` **absolute ceiling** (hard cap, default
|
|
|
|
|
/// [`ASK_RENDEZVOUS_CEILING`] = 4 h) past which the extending inactivity window never
|
|
|
|
|
/// parks a call, even against a perpetually-busy target. The composition root resolves
|
|
|
|
|
/// an optional override with [`resolve_ask_rendezvous_ceiling`]. Tests use it to shrink
|
|
|
|
|
/// the ceiling to milliseconds. Additive: callers that don't set it keep the default.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_ask_rendezvous_ceiling(mut self, ceiling: Duration) -> Self {
|
|
|
|
|
self.ask_rendezvous_ceiling = ceiling;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Attaches the monotonic **activity probe** (see [`AskActivityProbe`]) driving the
|
|
|
|
|
/// inactivity-window extension: between two window expiries, a strictly larger token
|
|
|
|
|
/// means the target is alive and working, so the window is re-armed instead of
|
|
|
|
|
/// expiring. Built by the composition root (the only layer mapping a display name to a
|
|
|
|
|
/// run-dir transcript). Additive: without a probe the rendezvous degrades to a single
|
|
|
|
|
/// flat window (legacy behaviour, zero regression).
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_activity_probe(mut self, probe: AskActivityProbe) -> Self {
|
|
|
|
|
self.activity_probe = Some(probe);
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Serves JSON-RPC messages from `transport`, tagging every processed
|
|
|
|
|
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
|
|
|
|
|
///
|
|
|
|
|
@ -406,43 +481,40 @@ impl McpServer {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
|
|
|
|
|
// (the target's `idea_reply`). Bound *only* that path with a finite outer
|
|
|
|
|
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry
|
|
|
|
|
// return a clean JSON-RPC error instead of parking the task forever. Every
|
|
|
|
|
// other tool dispatches unbounded — they never rendezvous.
|
|
|
|
|
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the
|
|
|
|
|
// target's `idea_reply`). Bound *only* that path with the **inactivity watchdog**
|
|
|
|
|
// (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]):
|
|
|
|
|
// race the dispatch against an inactivity window; on each window expiry, probe
|
|
|
|
|
// whether the target progressed (transcript grew). Progress ⇒ re-arm the window
|
|
|
|
|
// (extension) up to the absolute ceiling; genuine silence ⇒ the retryable no-reply
|
|
|
|
|
// error; ceiling reached while still active ⇒ the distinct non-retryable ceiling
|
|
|
|
|
// error. Every other tool dispatches unbounded — they never rendezvous.
|
|
|
|
|
let dispatch = self.service.dispatch(&self.project, command);
|
|
|
|
|
let result = if name == "idea_ask_agent" {
|
|
|
|
|
// Armed beacon : le rendezvous synchrone est désormais borné par le filet
|
|
|
|
|
// de sécurité serveur. Si « end » n'apparaît jamais après ce « armed », la
|
|
|
|
|
// cible n'a ni appelé `idea_reply` ni atteint son prompt-ready dans le délai.
|
|
|
|
|
application::diag!(
|
|
|
|
|
"[mcp] ask armed requester={requester_label} target={arg_target} \
|
|
|
|
|
task_len={task_len} timeout_ms={}",
|
|
|
|
|
task_len={task_len} window_ms={} ceiling_ms={}",
|
|
|
|
|
self.ask_rendezvous_timeout.as_millis(),
|
|
|
|
|
self.ask_rendezvous_ceiling.as_millis(),
|
|
|
|
|
);
|
|
|
|
|
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
|
|
|
|
|
Ok(result) => result,
|
|
|
|
|
Err(_elapsed) => {
|
|
|
|
|
// RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé
|
|
|
|
|
// dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce
|
|
|
|
|
// Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head +
|
|
|
|
|
// mark_idle) et purge le `Busy`/ticket de la cible — aucune fuite `Busy`.
|
|
|
|
|
application::diag!(
|
|
|
|
|
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \
|
|
|
|
|
timeout_ms={} elapsed_ms={}",
|
|
|
|
|
self.ask_rendezvous_timeout.as_millis(),
|
|
|
|
|
started.elapsed().as_millis(),
|
|
|
|
|
);
|
|
|
|
|
// Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même
|
|
|
|
|
// sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`,
|
|
|
|
|
// sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré.
|
|
|
|
|
return Err(rendezvous_no_reply_error(&arg_target));
|
|
|
|
|
match self
|
|
|
|
|
.run_ask_rendezvous(dispatch, &arg_target, &requester_label, started)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
// RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà
|
|
|
|
|
// été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop
|
|
|
|
|
// libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune
|
|
|
|
|
// fuite `Busy`).
|
|
|
|
|
AskRendezvousOutcome::Resolved(result) => result,
|
|
|
|
|
AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)),
|
|
|
|
|
AskRendezvousOutcome::CeilingActive => {
|
|
|
|
|
return Err(rendezvous_ceiling_active_error(&arg_target))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
dispatch.await
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Surface the processed delegation on the bus, tagged as the MCP door — the
|
|
|
|
|
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
|
|
|
|
|
// action is the tool name. No-op when no sink is attached.
|
|
|
|
|
@ -500,6 +572,77 @@ impl McpServer {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drives the `idea_ask_agent` synchronous rendezvous under the **inactivity
|
|
|
|
|
/// watchdog** (cf. [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]).
|
|
|
|
|
///
|
|
|
|
|
/// Thin adapter over the testable free function [`run_inactivity_watchdog`]: it
|
|
|
|
|
/// supplies this server's window, ceiling and (optional) activity probe, and maps the
|
|
|
|
|
/// watchdog verdict to an [`AskRendezvousOutcome`]. With no probe wired the watchdog
|
|
|
|
|
/// degrades to a single flat window (legacy behaviour, zero regression).
|
|
|
|
|
async fn run_ask_rendezvous(
|
|
|
|
|
&self,
|
|
|
|
|
dispatch: impl Future<Output = Result<application::OrchestratorOutcome, application::AppError>>,
|
|
|
|
|
target: &str,
|
|
|
|
|
requester_label: &str,
|
|
|
|
|
started: Instant,
|
|
|
|
|
) -> AskRendezvousOutcome {
|
|
|
|
|
let probe = self.activity_probe.clone();
|
|
|
|
|
let target_owned = target.to_owned();
|
|
|
|
|
let probe_fn = move || {
|
|
|
|
|
let probe = probe.clone();
|
|
|
|
|
let target = target_owned.clone();
|
|
|
|
|
async move {
|
|
|
|
|
match &probe {
|
|
|
|
|
Some(p) => p(target).await,
|
|
|
|
|
None => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// DRY : on réutilise l'**unique** implémentation du watchdog (couche application),
|
|
|
|
|
// la même que celle qui gouverne réellement le tour délégué dans le service. Ici
|
|
|
|
|
// c'est le filet de sécurité externe de l'adaptateur MCP.
|
|
|
|
|
let window_ms = self.ask_rendezvous_timeout.as_millis();
|
|
|
|
|
let ceiling_ms = self.ask_rendezvous_ceiling.as_millis();
|
|
|
|
|
let (t_ext, r_ext) = (target.to_owned(), requester_label.to_owned());
|
|
|
|
|
let (t_exp, r_exp) = (target.to_owned(), requester_label.to_owned());
|
|
|
|
|
let (t_ceil, r_ceil) = (target.to_owned(), requester_label.to_owned());
|
|
|
|
|
match application::run_inactivity_watchdog(
|
|
|
|
|
dispatch,
|
|
|
|
|
self.ask_rendezvous_timeout,
|
|
|
|
|
self.ask_rendezvous_ceiling,
|
|
|
|
|
started,
|
|
|
|
|
self.activity_probe.is_some(),
|
|
|
|
|
probe_fn,
|
|
|
|
|
move |elapsed| {
|
|
|
|
|
application::diag!(
|
|
|
|
|
"[mcp] ask window extended (signe de vie) requester={r_ext} target={t_ext} \
|
|
|
|
|
window_ms={window_ms} elapsed_ms={}",
|
|
|
|
|
elapsed.as_millis(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
move |elapsed| {
|
|
|
|
|
application::diag!(
|
|
|
|
|
"[mcp] ask EXPIRED (no progress) requester={r_exp} target={t_exp} \
|
|
|
|
|
window_ms={window_ms} elapsed_ms={}",
|
|
|
|
|
elapsed.as_millis(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
move |elapsed| {
|
|
|
|
|
application::diag!(
|
|
|
|
|
"[mcp] ask CEILING active requester={r_ceil} target={t_ceil} \
|
|
|
|
|
ceiling_ms={ceiling_ms} elapsed_ms={}",
|
|
|
|
|
elapsed.as_millis(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
application::WatchdogOutcome::Resolved(r) => AskRendezvousOutcome::Resolved(r),
|
|
|
|
|
application::WatchdogOutcome::NoReply => AskRendezvousOutcome::NoReply,
|
|
|
|
|
application::WatchdogOutcome::CeilingActive => AskRendezvousOutcome::CeilingActive,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds an MCP `tools/call` result with a single text content block.
|
|
|
|
|
@ -537,6 +680,44 @@ fn rendezvous_no_reply_error(target: &str) -> JsonRpcError {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Outcome of the inactivity-watchdog rendezvous ([`McpServer::run_ask_rendezvous`]),
|
|
|
|
|
/// folded into a `tools/call` result by the caller.
|
|
|
|
|
enum AskRendezvousOutcome {
|
|
|
|
|
/// The dispatch completed (success or a typed `AppError`) — returned unchanged.
|
|
|
|
|
Resolved(Result<application::OrchestratorOutcome, application::AppError>),
|
|
|
|
|
/// The inactivity window elapsed with no observable progress ⇒ the retryable
|
|
|
|
|
/// [`rendezvous_no_reply_error`]. Also the flat-window fallback when no probe is wired.
|
|
|
|
|
NoReply,
|
|
|
|
|
/// The absolute ceiling was reached while the target was still actively working ⇒ the
|
|
|
|
|
/// distinct, non-retryable [`rendezvous_ceiling_active_error`].
|
|
|
|
|
CeilingActive,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous reaches the
|
|
|
|
|
/// **absolute ceiling** while the target is **still actively working** (its transcript
|
|
|
|
|
/// kept growing across probes).
|
|
|
|
|
///
|
|
|
|
|
/// **Distinct** from [`rendezvous_no_reply_error`]: the target is not silent, so a blind
|
|
|
|
|
/// retry would stack a second heavy turn on a target already mid-task. Carried under the
|
|
|
|
|
/// dedicated [`error_codes::RENDEZVOUS_CEILING_ACTIVE`] code with `data.retryable = false`
|
|
|
|
|
/// so a caller can branch without parsing the message.
|
|
|
|
|
fn rendezvous_ceiling_active_error(target: &str) -> JsonRpcError {
|
|
|
|
|
let message = format!(
|
|
|
|
|
"target {target} is still actively working but the rendezvous ceiling was reached; \
|
|
|
|
|
do not retry blindly — check the target's in-progress work/branch (it may still \
|
|
|
|
|
finish and call idea_reply), then re-solicit lightly if needed"
|
|
|
|
|
);
|
|
|
|
|
JsonRpcError {
|
|
|
|
|
code: error_codes::RENDEZVOUS_CEILING_ACTIVE,
|
|
|
|
|
message,
|
|
|
|
|
data: Some(json!({
|
|
|
|
|
"code": "RENDEZVOUS_CEILING_ACTIVE",
|
|
|
|
|
"retryable": false,
|
|
|
|
|
"target": target,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
|
|
|
|
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
|
|
|
|
match err {
|
|
|
|
|
@ -548,3 +729,49 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
// NOTE: the inactivity-watchdog **algorithm** now lives in (and is unit-tested by)
|
|
|
|
|
// the `application` crate (`application::run_inactivity_watchdog`), the single source
|
|
|
|
|
// of truth that also governs the delegated turn. This adapter only maps its verdict to
|
|
|
|
|
// an `AskRendezvousOutcome`, so we keep here only the adapter-specific concerns: the
|
|
|
|
|
// error builders and the override resolvers.
|
|
|
|
|
|
|
|
|
|
/// The two error builders carry distinct JSON-RPC codes and the documented
|
|
|
|
|
/// `data.retryable` flags (no-reply ⇒ retryable; ceiling-active ⇒ not).
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_builders_carry_distinct_codes_and_retryable_flags() {
|
|
|
|
|
let no_reply = rendezvous_no_reply_error("architect");
|
|
|
|
|
assert_eq!(no_reply.code, error_codes::RENDEZVOUS_NO_REPLY);
|
|
|
|
|
assert_eq!(no_reply.data.as_ref().unwrap()["retryable"], serde_json::json!(true));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
no_reply.data.as_ref().unwrap()["code"],
|
|
|
|
|
serde_json::json!("TARGET_RETURNED_NO_REPLY")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let ceiling = rendezvous_ceiling_active_error("architect");
|
|
|
|
|
assert_eq!(ceiling.code, error_codes::RENDEZVOUS_CEILING_ACTIVE);
|
|
|
|
|
assert_eq!(ceiling.data.as_ref().unwrap()["retryable"], serde_json::json!(false));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
ceiling.data.as_ref().unwrap()["code"],
|
|
|
|
|
serde_json::json!("RENDEZVOUS_CEILING_ACTIVE")
|
|
|
|
|
);
|
|
|
|
|
assert_ne!(no_reply.code, ceiling.code);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The resolvers honour an override and fall back to their finite defaults; `Some(0)`
|
|
|
|
|
/// is treated as "no override" (never an instant collapse).
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolvers_apply_override_else_finite_default() {
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_timeout(Some(1234)), Duration::from_millis(1234));
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_timeout(Some(0)), ASK_RENDEZVOUS_TIMEOUT);
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_timeout(None), ASK_RENDEZVOUS_TIMEOUT);
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999));
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_ceiling(Some(0)), ASK_RENDEZVOUS_CEILING);
|
|
|
|
|
assert_eq!(resolve_ask_rendezvous_ceiling(None), ASK_RENDEZVOUS_CEILING);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|