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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user