diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 77c94b7..196f221 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1386,6 +1386,59 @@ pub async fn cancel_resume( Ok(state.session_limit_service.cancel_resume(id)) } +/// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a +/// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché +/// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2. +/// +/// Le front ne dispose que de l'`agent_id` ; on résout côté backend : +/// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry +/// structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`] +/// / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de +/// cible ⇒ `NOT_FOUND`. +/// - `conversation_id` : best-effort via la session structurée de l'agent +/// ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé). +/// +/// Délègue ensuite à +/// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume), +/// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au +/// front. Aucun nouvel événement. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live +/// cell hosts the agent). +#[tauri::command] +pub async fn set_resume_at( + agent_id: String, + resets_at_ms: i64, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let id = parse_agent_id(&agent_id)?; + + // Résolution agent→cellule : la registry des sessions vivantes est la source de + // vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`), + // puis le terminal (PTY). + let node_id = state + .structured_sessions + .node_for_agent(&id) + .or_else(|| state.terminal_sessions.node_for_agent(&id)) + .ok_or_else(|| { + ErrorDto::from(AppError::NotFound(format!( + "aucune cellule vivante pour l'agent {id}" + ))) + })?; + + // `conversation_id` best-effort : seule une session structurée vivante l'expose. + let conversation_id = state + .structured_sessions + .session_for_agent(&id) + .and_then(|s| s.conversation_id()); + + state + .session_limit_service + .confirm_human_resume(id, node_id, conversation_id, resets_at_ms); + Ok(()) +} + /// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2). /// /// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 939d79d..cb61d5e 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,7 @@ pub fn run() { commands::change_agent_profile, commands::agent_send, commands::cancel_resume, + commands::set_resume_at, commands::interrupt_agent, commands::delegation_delivered, commands::set_front_attached, diff --git a/crates/app-tauri/tests/session_limit_wiring.rs b/crates/app-tauri/tests/session_limit_wiring.rs index a4ad52d..ae1e2d8 100644 --- a/crates/app-tauri/tests/session_limit_wiring.rs +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -96,3 +96,72 @@ async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() { } assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus"); } + +/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries +/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande +/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin). +/// +/// La commande `#[tauri::command]` exige une `State` non constructible hors +/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState` +/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément +/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428). +#[tokio::test] +async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() { + let state = AppState::build(temp_path("appdata")); + let unknown = agent(99); + + assert!( + state.structured_sessions.node_for_agent(&unknown).is_none(), + "aucune session structurée vivante ⇒ pas de cellule" + ); + assert!( + state.terminal_sessions.node_for_agent(&unknown).is_none(), + "aucune session terminal vivante ⇒ pas de cellule" + ); + // Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise : + // la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin. + let resolved = state + .structured_sessions + .node_for_agent(&unknown) + .or_else(|| state.terminal_sessions.node_for_agent(&unknown)); + assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"); +} + +/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de +/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited` +/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`. +#[tokio::test] +async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() { + let state = AppState::build(temp_path("appdata")); + let mut rx = state.event_bus.raw_receiver(); + + // Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation. + let resets_at_ms = i64::MAX; + state + .session_limit_service + .confirm_human_resume(agent(8), node(80), None, resets_at_ms); + + let mut saw_rate_limited = false; + let mut saw_scheduled = false; + for _ in 0..32 { + match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { + Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => { + saw_rate_limited = true; + } + Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => { + saw_scheduled = true; + break; + } + Ok(Ok(_)) => continue, + _ => break, + } + } + assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)"); + assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)"); + + // Annulable par la même voie que l'auto. + assert!( + state.session_limit_service.cancel_resume(agent(8)), + "an armed human resume is cancellable" + ); +} diff --git a/crates/application/src/agent/session_limit.rs b/crates/application/src/agent/session_limit.rs index 0cc756f..09e35ea 100644 --- a/crates/application/src/agent/session_limit.rs +++ b/crates/application/src/agent/session_limit.rs @@ -120,24 +120,7 @@ impl SessionLimitService { fire_at_ms, conversation_id, } => { - self.events.publish(DomainEvent::AgentRateLimited { - agent_id, - resets_at_ms, - }); - // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule - // l'armement précédent (sans événement d'annulation : c'est interne). - self.disarm(agent_id); - let id = self.scheduler.arm( - fire_at_ms, - ScheduledTask::ResumeAgent { - agent_id, - node_id, - conversation_id, - }, - ); - self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id); - self.events - .publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }); + self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms); } ResumePlan::HumanFallback => { self.events.publish(DomainEvent::AgentRateLimited { @@ -152,6 +135,75 @@ impl SessionLimitService { } } + /// **(d) Filet humain (§21.1 niveau 3).** L'utilisateur a saisi l'heure de reset + /// pour un agent en limite **suspectée** (rien n'a matché automatiquement). On + /// construit une [`SessionLimit`] de source [`RateLimitSource::Human`], on calcule + /// le plan via [`plan_resume`] et on **arme exactement la même reprise** que la + /// branche auto : mêmes événements (`AgentRateLimited{Some}` + `AgentResumeScheduled`), + /// même dédoublonnage, même annulabilité via [`Self::cancel_resume`]. + /// + /// L'heure saisie est traitée par le domaine sans privilège particulier : un reset + /// déjà passé est clampé à `now` par [`plan_resume`] ⇒ reprise immédiate. Le cas + /// [`ResumePlan::HumanFallback`] est ici inatteignable (`resets_at_ms` est toujours + /// `Some`) ; on le traite en no-op défensif pour rester total. + pub fn confirm_human_resume( + &self, + agent_id: AgentId, + node_id: NodeId, + conversation_id: Option, + resets_at_ms: i64, + ) { + let now = self.clock.now_millis(); + let limit = SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human); + + if let ResumePlan::Scheduled { + fire_at_ms, + conversation_id, + } = plan_resume(now, &limit, conversation_id) + { + self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms)); + } + } + + /// Arme (ou ré-arme) une reprise **programmée** pour `agent_id`, fabrique commune aux + /// deux entrées (auto §21.1 niveaux 1/2 et filet humain niveau 3). Séquence stricte, + /// identique à l'origine — d'où **zéro régression** : publie `AgentRateLimited` + /// (avec l'heure de reset connue), **dédoublonne** l'armement précédent via + /// [`Self::disarm`] (interne, sans événement), arme le réveil via [`Scheduler::arm`], + /// mémorise le [`ScheduleId`], puis publie `AgentResumeScheduled`. + /// + /// `resets_at_ms` est l'heure de reset **annoncée à l'UI** (countdown) ; `fire_at_ms` + /// est l'échéance effective (déjà clampée anti-passé par le domaine). Les deux ne + /// coïncident que si le reset est futur — on conserve donc la sémantique d'origine en + /// publiant l'heure de reset brute, pas l'échéance clampée. + fn arm_scheduled( + &self, + agent_id: AgentId, + fire_at_ms: i64, + node_id: NodeId, + conversation_id: Option, + resets_at_ms: Option, + ) { + self.events.publish(DomainEvent::AgentRateLimited { + agent_id, + resets_at_ms, + }); + // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule + // l'armement précédent (sans événement d'annulation : c'est interne). + self.disarm(agent_id); + let id = self.scheduler.arm( + fire_at_ms, + ScheduledTask::ResumeAgent { + agent_id, + node_id, + conversation_id, + }, + ); + self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id); + self.events + .publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }); + } + /// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`] /// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du /// récepteur dans le runtime est LS7). Retire l'entrée armée (le réveil a tiré), diff --git a/crates/application/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs index bb3564d..ef08e1b 100644 --- a/crates/application/tests/session_limit_service.rs +++ b/crates/application/tests/session_limit_service.rs @@ -438,3 +438,204 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() { "pas d'AgentResumeCancelled quand le réveil a déjà tiré" ); } + +// =========================================================================== +// (d/LS8) Filet humain niveau 3 — `confirm_human_resume` +// =========================================================================== + +/// (a) Heure FUTURE saisie par l'humain ⇒ EXACTEMENT un `arm(fire_at_ms, ResumeAgent{..})` +/// avec `fire_at_ms == resets_at_ms` (futur), un `ScheduleId` armé, et les events +/// `AgentRateLimited{Some}` PUIS `AgentResumeScheduled` dans cet ordre — parité auto. +#[test] +fn confirm_human_resume_future_arms_and_emits_in_order() { + let env = env_at(NOW); + let reset = NOW + 90_000; + env.service + .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset); + + // Exactement un arm, bonne échéance, bonne tâche. + let armed = env.scheduler.armed(); + assert_eq!(armed.len(), 1, "exactement un arm"); + assert_eq!(armed[0].0, reset, "fire_at_ms == reset (futur)"); + assert_eq!( + armed[0].1, + ScheduledTask::ResumeAgent { + agent_id: aid(1), + node_id: nid(2), + conversation_id: Some("conv-1".to_owned()), + } + ); + // Un ScheduleId a bien été émis (armement actif). + assert_eq!(env.scheduler.issued().len(), 1, "un ScheduleId armé"); + + // Ordre des events : RateLimited puis ResumeScheduled. + assert_eq!( + env.bus.events(), + vec![ + DomainEvent::AgentRateLimited { + agent_id: aid(1), + resets_at_ms: Some(reset), + }, + DomainEvent::AgentResumeScheduled { + agent_id: aid(1), + fire_at_ms: reset, + }, + ] + ); +} + +/// (b) Heure PASSÉE saisie par l'humain (`resets_at_ms < now`) ⇒ clamp anti-passé : +/// `fire_at_ms == now` (reprise quasi-immédiate). L'event `AgentRateLimited` conserve +/// l'heure brute (passée) ; `AgentResumeScheduled` porte le `now` clampé. +#[test] +fn confirm_human_resume_past_reset_clamps_fire_at_to_now() { + let env = env_at(NOW); + let past = NOW - 30_000; + env.service + .confirm_human_resume(aid(1), nid(2), None, past); + + let armed = env.scheduler.armed(); + assert_eq!(armed.len(), 1); + assert_eq!(armed[0].0, NOW, "fire_at_ms clampé à now (jamais le passé)"); + + assert_eq!( + env.bus.events(), + vec![ + DomainEvent::AgentRateLimited { + agent_id: aid(1), + resets_at_ms: Some(past), // heure brute (passée) conservée dans l'event + }, + DomainEvent::AgentResumeScheduled { + agent_id: aid(1), + fire_at_ms: NOW, // clampé + }, + ] + ); +} + +/// (c) DÉDOUBLONNAGE CROISÉ — `confirm_human_resume` APRÈS un `on_rate_limited` déjà +/// armé pour le même agent ⇒ l'armement auto précédent est annulé (cancel interne du +/// 1er ScheduleId), un seul réveil reste actif, et AUCUN `AgentResumeCancelled` n'est +/// émis (dédoublonnage silencieux). On prouve l'unicité de l'armement actif : un +/// `cancel_resume` réussit une fois (true), un second échoue (false). +#[test] +fn confirm_human_resume_after_auto_dedups_single_active_arm() { + let env = env_at(NOW); + env.service + .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000)); + env.service + .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000); + + let issued = env.scheduler.issued(); + assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement"); + assert_eq!( + env.scheduler.cancels(), + vec![issued[0]], + "le ScheduleId auto précédent est cancel-é avant de réarmer (humain)" + ); + + // Aucun AgentResumeCancelled (dédoublonnage interne, silencieux). + assert!( + !env + .bus + .events() + .iter() + .any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })), + "le dédoublonnage croisé n'émet PAS AgentResumeCancelled" + ); + + // Unicité de l'armement actif : un seul cancel_resume aboutit. + assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true"); + assert!( + !env.service.cancel_resume(aid(1)), + "plus aucun armement après le premier cancel ⇒ false (une seule entrée)" + ); +} + +/// (c-inverse) `on_rate_limited` APRÈS un `confirm_human_resume` déjà armé pour le même +/// agent ⇒ symétrique : l'armement humain précédent est annulé (cancel interne), un seul +/// réveil reste actif, pas d'`AgentResumeCancelled`. +#[test] +fn auto_after_confirm_human_resume_dedups_single_active_arm() { + let env = env_at(NOW); + env.service + .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); + env.service + .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000)); + + let issued = env.scheduler.issued(); + assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement"); + assert_eq!( + env.scheduler.cancels(), + vec![issued[0]], + "le ScheduleId humain précédent est cancel-é avant de réarmer (auto)" + ); + + assert!( + !env + .bus + .events() + .iter() + .any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })), + "le dédoublonnage croisé n'émet PAS AgentResumeCancelled" + ); + + assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true"); + assert!( + !env.service.cancel_resume(aid(1)), + "plus aucun armement après le premier cancel ⇒ false (une seule entrée)" + ); +} + +/// (d) ANNULABILITÉ — `cancel_resume` après `confirm_human_resume` ⇒ renvoie `true` et +/// publie `AgentResumeCancelled` (l'armement humain s'annule par la MÊME voie que l'auto). +#[test] +fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() { + let env = env_at(NOW); + env.service + .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); + let issued = env.scheduler.issued(); + + assert!( + env.service.cancel_resume(aid(1)), + "cancel d'un réveil humain armé ⇒ true" + ); + assert_eq!(env.scheduler.cancels(), vec![issued[0]]); + assert!( + env.bus + .events() + .iter() + .any(|e| *e == DomainEvent::AgentResumeCancelled { agent_id: aid(1) }), + "AgentResumeCancelled doit être publié" + ); +} + +/// (e) PARITÉ auto/humain — à reset (futur) IDENTIQUE, `confirm_human_resume` produit +/// EXACTEMENT la même séquence d'events et le même `arm` (échéance + tâche) qu'`on_rate_limited` +/// dans son cas Scheduled. La source (Human vs Structured) n'a aucun effet observable. +#[test] +fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() { + let reset = NOW + 60_000; + + let auto = env_at(NOW); + auto.service + .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset)); + + let human = env_at(NOW); + human + .service + .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset); + + // Même séquence d'events. + assert_eq!( + human.bus.events(), + auto.bus.events(), + "parité auto/humain : même séquence d'events à reset identique" + ); + // Même armement (échéance + tâche). + assert_eq!( + human.scheduler.armed(), + auto.scheduler.armed(), + "parité auto/humain : même arm (fire_at_ms + ResumeAgent{{..}})" + ); +}