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:
2026-06-17 08:44:45 +02:00
parent 4fad0423e7
commit c480d2820a
5 changed files with 394 additions and 18 deletions

View File

@ -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é),