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:
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user