fix(orchestrator,agent): rendezvous cross-project réarmé par jeton d'activité live model-agnostique — #287 (le watchdog d'inactivité du rendez-vous expirait à 600s sur un tour structured actif car seul le transcript Claude servait de signe de vie; nouveau drain_with_readiness_announcements_and_activity_outcome exposant un jeton monotone AtomicU64 incrémenté par chaque ReplyEvent non terminal du tap send_with_tap, passé au watchdog via AskWatchdogActivity pour réarmer la fenêtre quelle que soit l'adapter; la sonde transcript devient fallback externe; QA verte: orchestrator_service 81/81, rendezvous 5/5, mcp_server 40/40, backend 87+7 ignored, cargo fmt --check; scripts/ hors périmètre)

This commit is contained in:
2026-09-08 20:29:48 +02:00
parent 4484a02b16
commit 77169101cb
5 changed files with 277 additions and 28 deletions

View File

@ -29,8 +29,8 @@ use domain::ports::{
SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
AgentProfile, ContextInjection, LivenessStrategy, McpCapability, McpConfigStrategy,
McpTransport, StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -4892,6 +4892,81 @@ struct BackgroundThenFinalSession {
service: Arc<OrchestratorService>,
}
struct TappedDelayedFinalSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for TappedDelayedFinalSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
Some(format!("engine-{}", self.id))
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
std::future::pending().await
}
async fn send_with_tap(
&self,
_prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
for event in [
ReplyEvent::Heartbeat,
ReplyEvent::Progress {
progress: domain::ports::ReplyProgress::new(
domain::ports::ReplyProgressSource::ProviderNative,
domain::ports::ReplyProgressKind::Message,
domain::ports::ReplyProgressStage::Delta,
"working",
),
},
ReplyEvent::Heartbeat,
] {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
tap.send(event).expect("tap receiver remains live");
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
Ok(Box::new(
vec![ReplyEvent::Final {
content: "late final".to_owned(),
}]
.into_iter(),
))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
struct SilentStructuredSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for SilentStructuredSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
Some(format!("engine-{}", self.id))
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
std::future::pending().await
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[async_trait]
impl AgentSession for BackgroundThenFinalSession {
fn id(&self) -> SessionId {
@ -4992,7 +5067,16 @@ fn structured_ask_fixture_with_background(
contexts: FakeContexts,
enable_background: bool,
) -> StructuredAskFixture {
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
structured_ask_fixture_with_profile(contexts, enable_background, claude_profile(), None)
}
fn structured_ask_fixture_with_profile(
contexts: FakeContexts,
enable_background: bool,
profile: AgentProfile,
liveness_probe: Option<application::AskLivenessProbe>,
) -> StructuredAskFixture {
let profiles = Arc::new(FakeProfiles::new(vec![profile]));
let sessions = Arc::new(TerminalSessions::new());
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
@ -5079,6 +5163,10 @@ fn structured_ask_fixture_with_background(
// insérée par `launch_structured`.
.with_structured(Arc::clone(&structured));
if let Some(probe) = liveness_probe {
service = service.with_ask_liveness_probe(probe);
}
if enable_background {
service = service
.with_background_tasks(
@ -5241,6 +5329,77 @@ async fn ask_warm_structured_target_reuses_session_without_relaunch() {
);
}
/// Régression #287 : la sonde transcript peut être stable (notamment inexistante pour
/// un provider non-Claude), tandis que `send_with_tap` publie réellement l'activité du
/// tour. Chaque événement non terminal doit réarmer la fenêtre jusqu'au `Final` tardif.
#[tokio::test]
async fn ask_structured_rearms_inactivity_window_from_send_with_tap() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let profile = claude_profile()
.with_liveness(LivenessStrategy::new(None, Some(30)).expect("valid short test window"));
let stable_probe: application::AskLivenessProbe =
Arc::new(|_root, _agent_id| Box::pin(async { Some(42) }));
let fx = structured_ask_fixture_with_profile(
FakeContexts::with_agent(&agent, "# persona"),
false,
profile,
Some(stable_probe),
);
fx.structured.insert_in_project(
project_id(),
Arc::new(TappedDelayedFinalSession { id: sid(9900) }),
aid(1),
nid(701),
);
let started = std::time::Instant::now();
let out = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("tap activity must keep the rendezvous alive until Final");
assert_eq!(out.reply.as_deref(), Some("late final"));
assert!(
started.elapsed() >= std::time::Duration::from_millis(70),
"the Final must arrive after multiple 30 ms inactivity windows"
);
}
/// Sans événement tap, sans progrès transcript et sans `Final`, le même chemin doit
/// conserver le timeout d'inactivité au bout d'une fenêtre complète.
#[tokio::test]
async fn ask_structured_without_tap_probe_progress_or_final_times_out() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let profile = claude_profile()
.with_liveness(LivenessStrategy::new(None, Some(30)).expect("valid short test window"));
let stable_probe: application::AskLivenessProbe =
Arc::new(|_root, _agent_id| Box::pin(async { Some(42) }));
let fx = structured_ask_fixture_with_profile(
FakeContexts::with_agent(&agent, "# persona"),
false,
profile,
Some(stable_probe),
);
fx.structured.insert_in_project(
project_id(),
Arc::new(SilentStructuredSession { id: sid(9901) }),
aid(1),
nid(702),
);
let started = std::time::Instant::now();
let err = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect_err("a fully silent structured turn must time out");
assert_eq!(err.code(), "PROCESS", "got {err:?}");
assert!(err.to_string().to_ascii_lowercase().contains("timed out"));
assert!(started.elapsed() < std::time::Duration::from_secs(1));
}
/// Régression live : une cible structurée peut déjà être vivante en PTY parce qu'elle
/// a été ouverte depuis la surface humaine/menu historique. Le chemin `ask` headless
/// doit alors créer une session structurée **sans fermer** ce PTY visible : les deux