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

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