feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)
Permet à l'humain de confirmer/forcer l'heure de reprise quand le niveau 2 a détecté une limite sans heure exploitable. - application/agent/session_limit.rs : refactor privé arm_scheduled (param resets_at_ms brut) partagé par on_rate_limited + nouvelle confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms) (source Human, réutilise la branche Scheduled, annulable). - app-tauri/commands.rs : commande set_resume_at(agent_id, resets_at_ms) (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - app-tauri/lib.rs : set_resume_at enregistrée après cancel_resume. Réutilise les événements existants (AgentRateLimited + AgentResumeScheduled), aucun nouvel événement. Tests : +6 session_limit_service, +2 wiring, verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<String>,
|
||||
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<String>,
|
||||
resets_at_ms: Option<i64>,
|
||||
) {
|
||||
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é),
|
||||
|
||||
@ -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{{..}})"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user