feat(orchestrator): watchdog d'inactivité réarmable + plafond du rendez-vous inter-agents

Redessine la borne de fin de tour du rendez-vous `idea_ask_agent` ⇄ `idea_reply` :
au lieu d'un timeout plat (qui coupait à 600 s un seul long tour de la cible, cf.
T7), la borne devient une **fenêtre d'inactivité réarmée** à chaque progrès observé
de la cible, sous un **plafond absolu** (défaut 4 h, réglable via
`IDEA_ASK_RENDEZVOUS_CEILING_MS`).

- Sonde d'activité (`transcript_activity_token`, inspector) : jeton monotone =
  octets cumulés des `.jsonl` de la cible. Croît même pendant un seul long tour
  sans `turn_duration` ⇒ détecte « vivant et au travail » mi-tour. Best-effort,
  sans effet de bord ; folder absent/illisible ⇒ « pas de progrès ».
- Watchdog (`run_inactivity_watchdog`, nouveau module `orchestrator/rendezvous`) :
  fenêtre réarmable + plafond, fallback timeout plat si aucune sonde (zéro régression).
- Issue typée distincte `TargetCeilingActive` (code `RENDEZVOUS_CEILING_ACTIVE`) :
  une cible **active** stoppée au plafond n'est jamais confondue avec un
  `TargetReturnedNoReply` (silence) ; le message guide « ne pas retenter à l'aveugle ».
- Câblage composition-root (`state.rs`) : sonde résolue nom→AgentId→run-dir transcript,
  branchée sur le service et sur l'McpServer (`AskActivityProbe`, `with_ask_ceiling`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 13:32:19 +02:00
parent 1efe2f11dc
commit 6050a6da5f
12 changed files with 1036 additions and 140 deletions

View File

@ -17,7 +17,7 @@
//! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector
//! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher
use domain::ports::RemotePath;
use domain::ports::{FileSystem, RemotePath};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
@ -74,6 +74,38 @@ pub(crate) fn supports_claude(profile: &AgentProfile) -> bool {
)
}
/// Cumulative byte size of **all** `.jsonl` transcripts in a Claude agent's per-run-dir
/// folder (`<home>/.claude/projects/<encoded-cwd>/`) — a monotonic **activity token** for
/// the `idea_ask_agent` rendezvous inactivity watchdog (server-side safety net).
///
/// Why bytes, not the `turn_duration` count: a target working in **one long turn** never
/// emits a `turn_duration` record until that turn ends, yet its transcript file keeps
/// **growing** as the model streams output. Summing transcript bytes therefore detects
/// "alive and working" even mid-turn — exactly the wedge the flat timeout mis-handled.
/// Best-effort and side-effect-free: a missing folder (cold start) or any unreadable file
/// contributes `0`; `None` only when the folder cannot be listed at all (treated by the
/// watchdog as "no observable progress"). Lives beside the path encoder so the transcript
/// layout knowledge stays in one tested seam.
pub async fn transcript_activity_token(
fs: &dyn FileSystem,
home_dir: &str,
cwd: &ProjectPath,
) -> Option<u64> {
let dir = claude_project_dir(home_dir, cwd);
let entries = fs.list(&dir).await.ok()?;
let mut total: u64 = 0;
for entry in entries {
if entry.is_dir || !entry.name.ends_with(".jsonl") {
continue;
}
let path = RemotePath::new(format!("{}/{}", dir.0, entry.name));
if let Ok(bytes) = fs.read(&path).await {
total = total.saturating_add(bytes.len() as u64);
}
}
Some(total)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -12,4 +12,5 @@ mod claude_paths;
mod claude_turn_watcher;
pub use claude::ClaudeTranscriptInspector;
pub use claude_paths::transcript_activity_token;
pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher;

View File

@ -48,10 +48,11 @@ pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::{ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher};
pub use inspector::{transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{
resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport,
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
MemoryTransport, StdioTransport,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -130,6 +130,16 @@ pub mod error_codes {
/// may re-ask, reminding the target to answer via `idea_reply`. The accompanying
/// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`.
pub const RENDEZVOUS_NO_REPLY: i32 = -32_001;
/// **Server-defined** (JSON-RPC reserved range -32000..=-32099): the synchronous
/// `idea_ask_agent` rendezvous hit the **absolute ceiling** while the target was
/// **still actively working** (its transcript kept growing). **Distinct** from
/// [`RENDEZVOUS_NO_REPLY`]: the target is *not* silent -- it simply outran the hard
/// ceiling. It is therefore **non-retryable** (a blind re-ask would pile a second
/// heavy turn on a target already mid-task); the caller must inspect the target's
/// in-progress work/branch before re-soliciting lightly. The accompanying `data`
/// carries `{ "code": "RENDEZVOUS_CEILING_ACTIVE", "retryable": false }`.
pub const RENDEZVOUS_CEILING_ACTIVE: i32 = -32_002;
}
/// Errors a [`Transport`] may surface.

View File

@ -36,6 +36,8 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::{resolve_ask_rendezvous_timeout, McpServer};
pub use server::{
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -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);
}
}