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,68 @@
//! Backoff exponencial con tope.
use std::time::Duration;
/// Calculador de backoff exponencial. Cada `next_delay()` devuelve el
/// delay actual y luego lo duplica, hasta saturar en `max`.
#[derive(Debug, Clone)]
pub struct Backoff {
base: Duration,
max: Duration,
current: Duration,
}
impl Backoff {
/// Crea un backoff que arranca en `base` y satura en `max`.
/// Si `base > max`, `base` se clampa a `max`.
pub fn new(base: Duration, max: Duration) -> Self {
let base = base.min(max);
Self { base, max, current: base }
}
/// Devuelve el delay actual y escala el siguiente (×2, capeado a `max`).
pub fn next_delay(&mut self) -> Duration {
let delay = self.current;
self.current = (self.current * 2).min(self.max);
delay
}
/// Vuelve al delay base (tras un éxito).
pub fn reset(&mut self) {
self.current = self.base;
}
/// Delay que devolvería el próximo `next_delay()` sin consumirlo.
pub fn peek(&self) -> Duration {
self.current
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escalates_then_caps() {
let mut b = Backoff::new(Duration::from_millis(100), Duration::from_millis(800));
assert_eq!(b.next_delay(), Duration::from_millis(100));
assert_eq!(b.next_delay(), Duration::from_millis(200));
assert_eq!(b.next_delay(), Duration::from_millis(400));
assert_eq!(b.next_delay(), Duration::from_millis(800));
assert_eq!(b.next_delay(), Duration::from_millis(800)); // capeado
}
#[test]
fn reset_returns_to_base() {
let mut b = Backoff::new(Duration::from_millis(100), Duration::from_secs(30));
b.next_delay();
b.next_delay();
b.reset();
assert_eq!(b.next_delay(), Duration::from_millis(100));
}
#[test]
fn base_clamped_to_max() {
let mut b = Backoff::new(Duration::from_secs(10), Duration::from_secs(1));
assert_eq!(b.next_delay(), Duration::from_secs(1));
}
}