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

@ -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

View File

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

View File

@ -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<AppState>` 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"
);
}

View File

@ -120,6 +120,70 @@ impl SessionLimitService {
fire_at_ms,
conversation_id,
} => {
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms);
}
ResumePlan::HumanFallback => {
self.events.publish(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms: None,
});
self.events.publish(DomainEvent::AgentRateLimitSuspected {
agent_id,
resets_at_ms: None,
});
}
}
}
/// **(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,
@ -139,18 +203,6 @@ impl SessionLimitService {
self.events
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
}
ResumePlan::HumanFallback => {
self.events.publish(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms: None,
});
self.events.publish(DomainEvent::AgentRateLimitSuspected {
agent_id,
resets_at_ms: None,
});
}
}
}
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
/// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du

View File

@ -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{{..}})"
);
}