#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système). #314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus. QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314. Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
328 lines
13 KiB
Rust
328 lines
13 KiB
Rust
//! [`SessionLimitService`] — orchestration applicative des **limites de session**
|
|
//! des agents (ARCHITECTURE §21.5) : **détecter → planifier → reprendre**, et
|
|
//! **annuler**.
|
|
//!
|
|
//! Service **pur-ports** (SOLID/hexagonal) : il ne dépend que de traits du domaine
|
|
//! ([`Clock`], [`Scheduler`], [`EventBus`]) et d'un port applicatif de reprise
|
|
//! ([`AgentResumer`], implémenté au composition root en LS7 par-dessus `LaunchAgent`).
|
|
//! Aucune dépendance vers un adapter concret ⇒ entièrement testable avec des fakes.
|
|
//!
|
|
//! # État en mémoire uniquement (§21.1-3)
|
|
//!
|
|
//! La seule mémoire du service est une table `agent_id → ScheduleId` des reprises
|
|
//! **armées** (pour pouvoir les annuler). Aucune persistance : à un redémarrage
|
|
//! d'IdeA le chemin `ListResumableAgents` existant prend le relais.
|
|
//!
|
|
//! # Dédoublonnage par agent (§21.10-4)
|
|
//!
|
|
//! Un agent n'a qu'**une** reprise armée à la fois : un second signal de limite
|
|
//! **rafraîchit** l'armement (annule l'ancien, arme le nouveau) au lieu d'empiler.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::ids::{AgentId, NodeId, ProjectId, RuntimeAgentKey, ScheduleId};
|
|
use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler};
|
|
use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
|
use domain::DomainEvent;
|
|
|
|
use crate::error::AppError;
|
|
|
|
/// Prompt **court** envoyé à l'agent au moment de la reprise automatique (§21.5).
|
|
///
|
|
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
|
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
|
/// contexte. Volontairement neutre et model-agnostique.
|
|
pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
|
|
|
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
|
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
|
/// ([`crate::agent::HandoffProvider`], [`crate::agent::ProviderSessionProvider`]) :
|
|
/// l'app-tauri le branche par-dessus le mécanisme de lancement existant
|
|
/// (`LaunchAgent` + `AgentSessionFactory`) avec [`domain::ports::SessionPlan::Resume`].
|
|
///
|
|
/// Le service ne sait **pas** relancer un agent lui-même (cela exige le `Project`, le
|
|
/// profil, le contexte préparé, le PTY… que seul `LaunchAgent` résout) ; il délègue
|
|
/// donc à ce port, en restant testable avec un fake.
|
|
#[async_trait]
|
|
pub trait AgentResumer: Send + Sync {
|
|
/// Relance/réattache l'agent `agent_id` dans sa cellule `node_id`, en reprenant la
|
|
/// conversation moteur `conversation_id` (`SessionPlan::Resume` côté lancement) et
|
|
/// en lui transmettant `resume_prompt` comme premier tour.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] si la relance échoue (profil/contexte introuvable, échec de
|
|
/// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`.
|
|
async fn resume(
|
|
&self,
|
|
project_id: ProjectId,
|
|
agent_id: AgentId,
|
|
node_id: NodeId,
|
|
conversation_id: Option<String>,
|
|
resume_prompt: &str,
|
|
) -> Result<(), AppError>;
|
|
}
|
|
|
|
/// Service d'orchestration des limites de session (§21.5).
|
|
pub struct SessionLimitService {
|
|
clock: Arc<dyn Clock>,
|
|
scheduler: Arc<dyn Scheduler>,
|
|
events: Arc<dyn EventBus>,
|
|
resumer: Arc<dyn AgentResumer>,
|
|
/// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire).
|
|
armed: Mutex<HashMap<RuntimeAgentKey, ScheduleId>>,
|
|
}
|
|
|
|
impl SessionLimitService {
|
|
/// Construit le service depuis ses ports injectés (composition root).
|
|
#[must_use]
|
|
pub fn new(
|
|
clock: Arc<dyn Clock>,
|
|
scheduler: Arc<dyn Scheduler>,
|
|
events: Arc<dyn EventBus>,
|
|
resumer: Arc<dyn AgentResumer>,
|
|
) -> Self {
|
|
Self {
|
|
clock,
|
|
scheduler,
|
|
events,
|
|
resumer,
|
|
armed: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// **(a) Détection → planification.** À partir d'un signal de limite
|
|
/// (`ReadinessSignal::RateLimited`/`TurnOutcome::RateLimited`) pour `agent_id` dans
|
|
/// la cellule `node_id`, construit un [`SessionLimit`] (source `Structured`),
|
|
/// calcule le plan via [`plan_resume`] et agit :
|
|
///
|
|
/// - [`ResumePlan::Scheduled`] ⇒ publie `AgentRateLimited`, **arme** la reprise via
|
|
/// [`Scheduler::arm`] (dédoublonnée : un éventuel armement antérieur du même agent
|
|
/// est annulé d'abord, §21.10-4), mémorise le [`ScheduleId`], puis publie
|
|
/// `AgentResumeScheduled`.
|
|
/// - [`ResumePlan::HumanFallback`] (heure de reset inconnue) ⇒ publie
|
|
/// `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` (filet humain ;
|
|
/// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement).
|
|
pub fn on_rate_limited(
|
|
&self,
|
|
project_id: ProjectId,
|
|
agent_id: AgentId,
|
|
node_id: NodeId,
|
|
conversation_id: Option<String>,
|
|
resets_at_ms: Option<i64>,
|
|
) {
|
|
let now = self.clock.now_millis();
|
|
let limit = SessionLimit::new(resets_at_ms, now, RateLimitSource::Structured);
|
|
|
|
match plan_resume(now, &limit, conversation_id) {
|
|
ResumePlan::Scheduled {
|
|
fire_at_ms,
|
|
conversation_id,
|
|
} => {
|
|
self.arm_scheduled(
|
|
project_id,
|
|
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,
|
|
project_id: ProjectId,
|
|
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(
|
|
project_id,
|
|
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,
|
|
project_id: ProjectId,
|
|
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).
|
|
let key = RuntimeAgentKey::new(project_id, agent_id);
|
|
self.disarm(key);
|
|
let id = self.scheduler.arm(
|
|
fire_at_ms,
|
|
ScheduledTask::ResumeAgent {
|
|
project_id,
|
|
agent_id,
|
|
node_id,
|
|
conversation_id,
|
|
},
|
|
);
|
|
self.armed
|
|
.lock()
|
|
.expect("session-limit mutex sain")
|
|
.insert(key, 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é),
|
|
/// relance l'agent via [`AgentResumer`] avec [`RESUME_PROMPT`], puis publie
|
|
/// `AgentResumed`.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] propagée par [`AgentResumer::resume`] (la relance a échoué) ; dans
|
|
/// ce cas `AgentResumed` n'est **pas** publié.
|
|
pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> {
|
|
let ScheduledTask::ResumeAgent {
|
|
project_id,
|
|
agent_id,
|
|
node_id,
|
|
conversation_id,
|
|
} = task
|
|
else {
|
|
return Err(AppError::Invalid(
|
|
"session-limit service received a non-resume scheduled task".to_owned(),
|
|
));
|
|
};
|
|
|
|
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non).
|
|
self.disarm(RuntimeAgentKey::new(project_id, agent_id));
|
|
|
|
self.resumer
|
|
.resume(
|
|
project_id,
|
|
agent_id,
|
|
node_id,
|
|
conversation_id,
|
|
RESUME_PROMPT,
|
|
)
|
|
.await?;
|
|
|
|
self.events.publish(DomainEvent::AgentResumed { agent_id });
|
|
Ok(())
|
|
}
|
|
|
|
/// **(c) Annulation.** Désarme la reprise auto de `agent_id` (socle du « annulable »).
|
|
/// Retrouve le [`ScheduleId`], appelle [`Scheduler::cancel`] et, **seulement si**
|
|
/// l'annulation a réussi, retire l'entrée et publie `AgentResumeCancelled`.
|
|
///
|
|
/// Renvoie `true` ssi la reprise a effectivement été annulée.
|
|
///
|
|
/// # Course « cancel pile au tir » (vigilance QA, LS3)
|
|
/// Sous runtime multi-thread, le réveil peut tirer **pile** au moment de l'annulation :
|
|
/// [`Scheduler::cancel`] renvoie alors `false` (déjà tiré). Dans ce cas on **ne
|
|
/// publie pas** `AgentResumeCancelled` et on **laisse l'entrée** (l'`execute_resume`
|
|
/// en cours la retirera) : la reprise **suit son cours**, cohérent et sans
|
|
/// événement trompeur.
|
|
pub fn cancel_resume(&self, agent_id: AgentId) -> bool {
|
|
let ids = {
|
|
let armed = self.armed.lock().expect("session-limit mutex sain");
|
|
armed
|
|
.iter()
|
|
.filter_map(|(key, id)| (key.agent_id == agent_id).then_some((*key, *id)))
|
|
.collect::<Vec<_>>()
|
|
};
|
|
if ids.is_empty() {
|
|
return false; // aucune reprise armée pour cet agent.
|
|
}
|
|
|
|
let mut cancelled_any = false;
|
|
for (key, id) in ids {
|
|
if self.scheduler.cancel(id) {
|
|
self.armed
|
|
.lock()
|
|
.expect("session-limit mutex sain")
|
|
.remove(&key);
|
|
cancelled_any = true;
|
|
}
|
|
}
|
|
if cancelled_any {
|
|
self.events
|
|
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
|
true
|
|
} else {
|
|
// Déjà tiré : la reprise suivra son cours, pas d'événement d'annulation.
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent
|
|
/// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie
|
|
/// aucun événement** (contrairement à [`Self::cancel_resume`]).
|
|
fn disarm(&self, key: RuntimeAgentKey) {
|
|
let previous = self
|
|
.armed
|
|
.lock()
|
|
.expect("session-limit mutex sain")
|
|
.remove(&key);
|
|
if let Some(id) = previous {
|
|
self.scheduler.cancel(id);
|
|
}
|
|
}
|
|
}
|