feat(sandokan-lifecycle): A4 — primitivas de lifecycle agnósticas

Nuevo crate runtime/sandokan-lifecycle: lógica pura reutilizable por
cualquier supervisor de procesos (shuma, matilda Ghost, charka-shadow,
mirada). Sin syscalls, sin proceso, sin UI.

Módulos:
- backoff   — Backoff exponencial con tope
- ttl       — Ttl anclado a Instant
- quota     — ResourceQuota + check_quota + Breach + QuotaAction
- restart   — RestartPolicy + RestartTracker (conteo + backoff)
- state     — LifecycleState (Pending/Running/Exited/Failed/Killed)

15 tests verdes. cargo check --workspace verde.

Variante segura de A4: se crea la library limpia sin tocar shuma-core
(módulo maduro). La migración de WorkspaceManager a consumir estas
primitivas queda registrada como A4.2 (refactor diferido, no urgente).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 00:32:52 +00:00
parent 67c0fcad11
commit 545dd59c72
10 changed files with 487 additions and 0 deletions
@@ -0,0 +1,115 @@
//! Política de restart con conteo + backoff exponencial.
use crate::backoff::Backoff;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Política declarativa de restart.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestartPolicy {
/// Si reintentar tras una salida con fallo.
pub on_failure: bool,
/// Máximo de restarts. `0` = infinito.
pub max_restarts: u32,
}
impl Default for RestartPolicy {
fn default() -> Self {
Self { on_failure: false, max_restarts: 0 }
}
}
/// Estado mutable de restart de una entidad supervisada. Combina la
/// política con un `Backoff` y el conteo de intentos consumidos.
#[derive(Debug, Clone)]
pub struct RestartTracker {
policy: RestartPolicy,
backoff: Backoff,
count: u32,
}
impl RestartTracker {
pub fn new(policy: RestartPolicy, backoff: Backoff) -> Self {
Self { policy, backoff, count: 0 }
}
/// Registra un fallo. Devuelve `Some(delay)` con el backoff a esperar
/// antes del próximo intento, o `None` si no se debe reintentar
/// (política desactivada o `max_restarts` agotado).
pub fn on_failure(&mut self) -> Option<Duration> {
if !self.policy.on_failure {
return None;
}
if self.policy.max_restarts != 0 && self.count >= self.policy.max_restarts {
return None;
}
self.count += 1;
Some(self.backoff.next_delay())
}
/// Registra un éxito: resetea conteo y backoff.
pub fn on_success(&mut self) {
self.count = 0;
self.backoff.reset();
}
/// Cantidad de restarts consumidos.
pub fn count(&self) -> u32 {
self.count
}
}
#[cfg(test)]
mod tests {
use super::*;
fn backoff() -> Backoff {
Backoff::new(Duration::from_millis(100), Duration::from_secs(30))
}
#[test]
fn disabled_policy_never_restarts() {
let mut t = RestartTracker::new(
RestartPolicy { on_failure: false, max_restarts: 0 },
backoff(),
);
assert!(t.on_failure().is_none());
}
#[test]
fn respects_max_restarts() {
let mut t = RestartTracker::new(
RestartPolicy { on_failure: true, max_restarts: 3 },
backoff(),
);
assert!(t.on_failure().is_some());
assert!(t.on_failure().is_some());
assert!(t.on_failure().is_some());
assert!(t.on_failure().is_none()); // 4º agota la cuota
assert_eq!(t.count(), 3);
}
#[test]
fn infinite_when_max_zero() {
let mut t = RestartTracker::new(
RestartPolicy { on_failure: true, max_restarts: 0 },
backoff(),
);
for _ in 0..100 {
assert!(t.on_failure().is_some());
}
}
#[test]
fn backoff_escalates_then_success_resets() {
let mut t = RestartTracker::new(
RestartPolicy { on_failure: true, max_restarts: 0 },
backoff(),
);
assert_eq!(t.on_failure(), Some(Duration::from_millis(100)));
assert_eq!(t.on_failure(), Some(Duration::from_millis(200)));
t.on_success();
assert_eq!(t.count(), 0);
assert_eq!(t.on_failure(), Some(Duration::from_millis(100)));
}
}