feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)

Branche le SessionLimitService dans l'application Tauri et expose la
surface de reprise/annulation au front.

- application/agent/lifecycle.rs : LaunchAgentOutput.profile exposé
  (None sur réattache/idempotent, Some sur lancement effectif).
- application/terminal/registry.rs : StructuredSessions::meta_for_session()
  (lookup agent/node par SessionId pour le tap niveau 1).
- app-tauri/state.rs : ResumeContext(s), AppAgentResumer (impl du port
  AgentResumer au-dessus de LaunchAgent), instanciation + câblage du
  SessionLimitService (TokioScheduler + drain des réveils) dans AppState::build.
- app-tauri/commands.rs : taps niveau 1 (agent_send) et niveau 2
  (launch_agent, parser regex confiné), alimentation de resume_contexts,
  commande cancel_resume.
- app-tauri/lib.rs : enregistrement de cancel_resume dans le handler.
- app-tauri/Cargo.toml : dépendance async-trait.

Tests : session_limit_wiring.rs (2 tests de composition) + meta_for_session
dans structured_registry_d1.rs ; fixtures dto_agents/dto_chat ajustées
(profile: None). Tout vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 07:56:08 +02:00
parent ea94e756e2
commit 9df592389c
10 changed files with 406 additions and 3 deletions

View File

@ -936,6 +936,15 @@ pub struct LaunchAgentOutput {
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
/// `assigned_conversation_id`.
pub structured: Option<StructuredSessionDescriptor>,
/// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7,
/// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro
/// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer —
/// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection
/// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans
/// reconstruire la frontière infra côté domaine. `None` sur une **réattache**
/// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors
/// rien (best-effort, le tour est sans nouvelle session).
pub profile: Option<AgentProfile>,
}
/// Registry mapping each [`ProjectorKey`] to its concrete
@ -1331,6 +1340,8 @@ impl LaunchAgent {
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Réattache (rebind de vue) : aucun profil re-résolu.
profile: None,
});
}
}
@ -1350,6 +1361,8 @@ impl LaunchAgent {
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Idempotent (pas de respawn) : aucun profil re-résolu.
profile: None,
});
}
}
@ -1388,6 +1401,8 @@ impl LaunchAgent {
node_id,
conversation_id: existing.conversation_id(),
}),
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
profile: None,
});
}
}
@ -1605,6 +1620,9 @@ impl LaunchAgent {
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
engine_session_id: None,
structured: None,
// Lancement effectif : profil résolu exposé pour la sélection du détecteur
// de limite niveau 2 (§21.10-4) côté câblage.
profile: Some(profile.clone()),
})
}
@ -1690,6 +1708,9 @@ impl LaunchAgent {
node_id,
conversation_id: engine_session_id,
}),
// Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne
// l'arme pas — `applies` est faux pour un profil structuré, §21.10-4).
profile: Some(profile.clone()),
})
}

View File

@ -380,6 +380,22 @@ impl StructuredSessions {
})
}
/// Résout les coordonnées `(agent_id, node_id)` d'une session structurée par son
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
///
/// Jumeau « inverse » de [`Self::live_agents`] : là où `live_agents` énumère tout,
/// celui-ci fait un lookup direct par id. Le pump structuré (`agent_send`) n'a en
/// main que le `SessionId` du tour ; ce lookup lui rend l'agent et la cellule à
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
#[must_use]
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId)> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| (e.agent_id, e.node_id)))
}
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
///
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple

View File

@ -106,6 +106,24 @@ fn structured_insert_resolve_and_remove() {
assert!(reg.session_for_agent(&a).is_none());
}
#[test]
fn structured_meta_for_session_resolves_agent_and_node() {
// LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver
// l'agent + la cellule hôte à passer au service de limite. Lookup direct par id.
let reg = StructuredSessions::new();
let s = sid(1);
let a = aid(10);
let n = nid(100);
reg.insert(fake(s), a, n);
assert_eq!(reg.meta_for_session(&s), Some((a, n)));
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
assert_eq!(reg.meta_for_session(&sid(999)), None);
reg.remove(&s);
assert_eq!(reg.meta_for_session(&s), None);
}
#[test]
fn structured_live_agents_lists_triples() {
let reg = StructuredSessions::new();