feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler

Introduit l'abstraction de planification pour la reprise différée à la
levée d'une limite de session :
- domaine : trait Scheduler + enum ScheduledTask (ports.rs), ScheduleId
  via typed_id! (ids.rs), re-exports (lib.rs).
- infra : TokioScheduler (scheduler/mod.rs, nouveau) + pub mod scheduler
  et re-export (lib.rs).

Tests QA inline (#[cfg(test)]) : 7 tests scheduler (3× sans flaky).
`cargo test -p infrastructure` = 195 passed / 0 failed ; domaine + infra
builds 0 warning ; LS1/LS2 toujours verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:07:48 +02:00
parent a1755e51bc
commit 253310bb3e
5 changed files with 389 additions and 5 deletions

View File

@ -92,3 +92,10 @@ typed_id!(
/// Identifies a node in a [`crate::layout::LayoutTree`].
NodeId
);
typed_id!(
/// Identifies one armed one-shot wake-up of a [`crate::ports::Scheduler`]
/// (ARCHITECTURE §21.4). Opaque, cancellable handle returned by
/// [`crate::ports::Scheduler::arm`] and consumed by
/// [`crate::ports::Scheduler::cancel`].
ScheduleId
);

View File

@ -65,8 +65,8 @@ mod validation;
pub use error::DomainError;
pub use ids::{
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
WindowId,
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
@ -145,6 +145,6 @@ pub use ports::{
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError,
TemplateStore,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
StoreError, TemplateStore,
};

View File

@ -29,7 +29,7 @@ use thiserror::Error;
use crate::agent::AgentManifest;
use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId};
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::permission::ProjectPermissions;
@ -262,6 +262,34 @@ pub enum ReplyEvent {
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
/// [`Scheduler`] (ARCHITECTURE §21.4).
///
/// **Donnée pure, pas de closure** : à l'image du dispatch de l'orchestrateur
/// (§14.3, où une requête est une *donnée* validée puis dispatchée), une tâche
/// programmée est une **valeur** qui franchit la frontière, jamais un effet ni une
/// fonction. C'est ce qui garde le port [`Scheduler`] sans aucune dépendance vers
/// l'application : l'adapter ne fait que **remettre** cette donnée à un drain
/// applicatif, qui seul l'exécute.
///
/// Enum **extensible** : d'autres intentions programmées pourront s'ajouter sans
/// toucher au port (Open/Closed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScheduledTask {
/// Reprendre un agent à l'heure de reset de sa limite de session (§21) : relancer
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
ResumeAgent {
/// L'agent à reprendre.
agent_id: AgentId,
/// La cellule (nœud du layout) qui héberge sa session.
node_id: NodeId,
/// Id de conversation du moteur à reprendre (`None` ⇒ reprise dégradée sans
/// id, comme le reste du chemin de reprise §15).
conversation_id: Option<String>,
},
}
// ---------------------------------------------------------------------------
// Per-port error types
// ---------------------------------------------------------------------------
@ -1266,3 +1294,50 @@ pub trait IdGenerator: Send + Sync {
/// Returns a fresh UUID.
fn new_uuid(&self) -> uuid::Uuid;
}
/// Minuterie **one-shot annulable** (ARCHITECTURE §21.4) — le **seul** port neuf de
/// la feature « limites de session ». Là où [`Clock`] dit *quelle heure il est*, ce
/// port *réveille* à une échéance absolue donnée.
///
/// # Interface Segregation (§21.8-I)
/// Volontairement minimal — `arm` / `cancel`, rien d'autre — et **distinct** de
/// [`Clock`] : un consommateur qui n'a besoin que de « réveille-moi à T » ne dépend
/// pas d'un store ni d'une horloge.
///
/// # Synchrone (pas d'`async_trait`)
/// Comme [`Clock`], [`IdGenerator`] et [`EventBus`], ce port est **non bloquant** :
/// `arm` ne fait qu'enregistrer une minuterie (l'attente réelle se passe en tâche de
/// fond dans l'adapter) et `cancel` ne fait qu'annuler — aucun `await` au point
/// d'appel. On le garde donc en `fn` simple, sans payer le boxing d'`async_trait`.
///
/// # Remise de la tâche échue — dispatch par DONNÉE (§14.3, §21.4)
/// Le port **n'exécute jamais** la reprise (aucune dépendance vers l'application).
/// À l'échéance, l'adapter **pousse la [`ScheduledTask`] (une valeur)** dans un canal
/// fourni à sa construction, que l'application **draine** et exécute — exactement le
/// patron du watcher d'orchestrateur, qui dispatche une requête-donnée vers
/// l'`OrchestratorService`. Aucune closure ne franchit la frontière domaine.
///
/// # État en mémoire (§21.1-3)
/// Aucune persistance : un réveil armé ne survit pas à un redémarrage d'IdeA (le
/// chemin `ListResumableAgents` existant prend alors le relais).
///
/// # Substituabilité (Liskov)
/// Tout adapter (le `TokioScheduler` réel comme un fake de test qui tire à la
/// demande) respecte le même contrat : `arm` rend un id annulable ; `cancel` renvoie
/// `true` ssi il a effectivement désarmé un réveil non encore tiré.
pub trait Scheduler: Send + Sync {
/// Arme une minuterie one-shot à `deadline_ms` (**époche-millisecondes absolues**,
/// cohérent avec [`Clock::now_millis`] et `ResumePlan::Scheduled.fire_at_ms`). À
/// l'échéance, l'adapter remet `task` au drain applicatif (cf. doc du trait).
/// Renvoie un [`ScheduleId`] annulable.
///
/// `deadline_ms` **≤ now** ⇒ déclenchement **au plus tôt** (immédiat). Le clamp
/// anti-passé est déjà assuré en amont par `plan_resume` (domaine) ; ce contrat ne
/// fait que garantir qu'une échéance passée ne « se perd » pas.
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
/// Annule un réveil armé **non encore tiré**. Idempotent et **sans erreur** :
/// renvoie `true` s'il a effectivement été désarmé, `false` s'il était inconnu ou
/// déjà tiré. C'est ce qui sous-tend la « reprise auto **annulable** » (§21.1-4).
fn cancel(&self, id: ScheduleId) -> bool;
}