Files
Loki/rust/loki-rs/src/chat/lease.rs
T
Claude b5649eb506 Lot 6 : un tour de discussion de bout en bout en Rust (chat::generate)
Port d'EnqueueOrStart, StartTurn, generate, drainQueued,
startQueuedIfAny, dropQueued et compactAndPublish :
- envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis
  en file si un tour tourne, sinon moteur prêt (/health), verrou du tour
  puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ;
- tour : compaction de début de tour, vue d'envoi (tête du Go, historique
  sans <context_update>, préambule fusionné comme InjectSkills), boucle
  dont chaque événement devient un delta du journal, historique rangé
  (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés),
  contexte estimé si le moteur ne le compte pas, compaction de fin de tour ;
- fin : turn_done, journal coalescé, verrou et bail rendus, discussion
  enregistrée, file abandonnée après un stop ou message suivant lancé,
  sinon Web Push ;
- stop : le tour en cours, ou le détenteur Go du moteur.

chat::history porte historyFromView, dropStrayNudges, le rappel des pages
mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du
journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en
mémoire seulement. EngineLease::interrupted ne garde plus de verrou à
travers une attente.

Écarts : boucle de vérification du mode Code, critères pour la
compaction, préchauffage, télémétrie, retry_patterns personnalisés.

Tests de bout en bout : tour complet (outil, réponse, bail rendu,
discussion enregistrée, doublon), message en file injecté entre deux
étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:00:17 +00:00

213 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bail du moteur — le verrou de génération du Go (`conv.Generating`), pris
//! par une conversation tenue par Rust (`web_internal_lease.go`).
//!
//! Le verrou est partagé avec les tâches planifiées, le bench et l'optimiseur,
//! qui restent au Go : un tour Rust doit le tenir du début à la fin, sinon une
//! tâche partirait vers le même moteur en plein tour.
//!
//! [`EngineLease::acquire`] prend le bail et le renouvelle en tâche de fond
//! (au tiers de sa durée). Le Go le rompt (Reset, bascule) ou y signale un
//! stop : [`EngineLease::interrupted`] le dit au tour. Une façade tuée ne
//! renouvelle plus : le Go rend le verrou à l'expiration. [`EngineLease::release`]
//! le rend tout de suite ; un bail lâché sans `release` est rendu en tâche de
//! fond.
use std::time::Duration;
use hyper::StatusCode;
use serde_json::{Value, json};
use tokio::sync::watch;
use tokio::task::JoinHandle;
use crate::store::Store;
/// Durée d'un bail par défaut (`extLeaseDefaultTTL`).
pub const LEASE_TTL: Duration = Duration::from_secs(30);
const PATH_LEASE: &str = "/internal/v1/engine/lease";
const PATH_RENEW: &str = "/internal/v1/engine/lease/renew";
const PATH_RELEASE: &str = "/internal/v1/engine/lease/release";
const PATH_STOP: &str = "/internal/v1/engine/stop";
/// Refus de prendre le bail.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeaseError {
/// Le moteur est tenu (tour, tâche planifiée, bench, optimiseur). `error`
/// est le message du Go (`busyReason`) ; la tâche est nommée s'il s'agit
/// d'une tâche planifiée.
Busy {
error: String,
task_id: Option<String>,
task_name: Option<String>,
},
/// Go injoignable ou réponse inattendue.
Other(String),
}
impl std::fmt::Display for LeaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LeaseError::Busy { error, .. } => f.write_str(error),
LeaseError::Other(e) => write!(f, "bail du moteur : {e}"),
}
}
}
/// État du bail vu du tour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaseSignal {
Held,
/// Stop demandé côté Go : le tour s'arrête, le bail tient jusqu'à `release`.
Stop,
/// Bail rompu (Reset ou bascule côté Go, ou expiré) : le verrou n'est plus
/// à nous.
Lost,
}
/// Le bail tenu ; rendu par [`Self::release`] ou à la destruction.
pub struct EngineLease {
id: String,
store: Store,
signal: watch::Receiver<LeaseSignal>,
renew: JoinHandle<()>,
released: bool,
}
impl EngineLease {
/// Prend le verrou de génération pour `ttl` (borné par le Go à 5 s – 5 min).
pub async fn acquire(store: Store, ttl: Duration) -> Result<Self, LeaseError> {
let body = json!({ "ttl_ms": ttl.as_millis() as u64 });
let (status, v) = store
.internal_post(PATH_LEASE, &body)
.await
.map_err(LeaseError::Other)?;
match status {
StatusCode::OK => {}
StatusCode::CONFLICT => {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
return Err(LeaseError::Busy {
error: s("error").unwrap_or_else(|| "génération en cours".into()),
task_id: s("task_id"),
task_name: s("task_name"),
});
}
s => return Err(LeaseError::Other(format!("{PATH_LEASE} : {s} {v}"))),
}
let id = v
.get("lease")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| LeaseError::Other(format!("{PATH_LEASE} : réponse sans bail {v}")))?
.to_string();
let granted = v
.get("ttl_ms")
.and_then(Value::as_u64)
.map(Duration::from_millis)
.unwrap_or(ttl);
let (tx, rx) = watch::channel(LeaseSignal::Held);
let renew = tokio::spawn(renew_loop(store.clone(), id.clone(), granted / 3, tx));
Ok(EngineLease {
id,
store,
signal: rx,
renew,
released: false,
})
}
pub fn id(&self) -> &str {
&self.id
}
/// État courant (dernier renouvellement).
pub fn signal(&self) -> LeaseSignal {
*self.signal.borrow()
}
/// Se résout au premier stop ou à la perte du bail, et rend lequel.
pub async fn interrupted(&mut self) -> LeaseSignal {
let got = self
.signal
.wait_for(|s| *s != LeaseSignal::Held)
.await
.map(|s| *s);
match got {
Ok(s) => s,
// Tâche de renouvellement terminée sans signal : rien à attendre.
Err(_) => std::future::pending().await,
}
}
/// Rend le verrou maintenant.
pub async fn release(mut self) {
self.released = true;
self.renew.abort();
release(&self.store, &self.id).await;
}
}
impl Drop for EngineLease {
fn drop(&mut self) {
self.renew.abort();
if self.released {
return;
}
// Lâché sans release (tour annulé, panique) : rendu en tâche de fond.
// Hors runtime, l'expiration côté Go s'en charge.
if let Ok(rt) = tokio::runtime::Handle::try_current() {
let (store, id) = (self.store.clone(), std::mem::take(&mut self.id));
rt.spawn(async move { release(&store, &id).await });
}
}
}
async fn release(store: &Store, id: &str) {
if let Err(e) = store
.internal_post(PATH_RELEASE, &json!({ "lease": id }))
.await
{
eprintln!("[lease] libération du bail échouée (le Go le rendra à l'expiration) : {e}");
}
}
/// Renouvelle jusqu'à la perte du bail. Une erreur réseau n'arrête pas la
/// boucle : le prochain essai peut passer avant l'expiration.
async fn renew_loop(store: Store, id: String, every: Duration, tx: watch::Sender<LeaseSignal>) {
let mut tick = tokio::time::interval(every.max(Duration::from_millis(100)));
tick.tick().await; // le premier top est immédiat : le bail vient d'être pris
loop {
tick.tick().await;
match store
.internal_post(PATH_RENEW, &json!({ "lease": id }))
.await
{
Ok((StatusCode::OK, v)) => {
if v.get("stop").and_then(Value::as_bool) == Some(true) {
tx.send_if_modified(|s| {
let was = *s;
if was == LeaseSignal::Held {
*s = LeaseSignal::Stop;
}
was == LeaseSignal::Held
});
}
}
Ok((StatusCode::GONE, _)) => {
tx.send_replace(LeaseSignal::Lost);
return;
}
Ok((s, v)) => eprintln!("[lease] renouvellement : {s} {v}"),
Err(e) => eprintln!("[lease] renouvellement : {e}"),
}
}
}
/// Arrête le détenteur Go du moteur (tâche planifiée, bench, optimiseur) :
/// le bouton stop du chat, quand ce n'est pas un tour Rust qui tourne.
pub async fn engine_stop(store: &Store) -> Result<(), String> {
match store.internal_post(PATH_STOP, &json!({})).await? {
(StatusCode::NO_CONTENT, _) => Ok(()),
(s, v) => Err(format!("{PATH_STOP} : {s} {v}")),
}
}