feat(shipote): throughput card + rate-limit + snapshot incremental (fase Q)

- shipote-shell Flow channels card extiende con bytes_total + bytes/s
  por socket. Lookup helper evita borrows en closures.
- DiscernPolicy.max_bytes_per_sec: splitter task hace sleep proporcional
  al tamaño de chunk tras cada broadcast. Token-bucket simple v1.
- WorkspaceManager.dirty: AtomicBool. mark_dirty() en mutaciones que
  afectan al snapshot. save_snapshot skip si clean y path existe.
  restore_snapshot resetea dirty=false (hidratación no es mutation).

85 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 26, shipote-discern 5, yahweh-provider-fs 3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-11 16:20:50 +00:00
parent 3486949d24
commit 18c0344a52
5 changed files with 134 additions and 25 deletions
@@ -181,10 +181,18 @@ impl WorkspaceManager {
}
}
/// Escribe snapshot a disco.
/// Escribe snapshot a disco. Si `is_dirty()` es false **y** el path
/// existe (snapshot previo válido), skip la escritura.
pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
if !self.is_dirty() && path.exists() {
info!(path = %path.display(), "snapshot SKIPPED (clean)");
return Ok(());
}
let snap = self.snapshot().await;
snap.write(path)?;
// Clear dirty: lo que está en disco es el current state.
self.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
Ok(())
}
@@ -245,6 +253,11 @@ impl WorkspaceManager {
out.saved_pipelines_restored += 1;
}
out.live_pipelines = snap.live_pipelines;
// Restore no cuenta como mutación — lo que está en disco es lo
// que acabamos de cargar. Sin esto, el próximo SIGTERM siempre
// re-escribiría aunque no hubiese cambios reales.
self.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
info!(
workspaces = out.workspaces_restored,
saved_pipelines = out.saved_pipelines_restored,
@@ -304,6 +317,24 @@ mod tests {
assert!(restored_ids.contains(&id2));
}
#[tokio::test]
async fn save_snapshot_skips_when_clean() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let _ = mgr.create(sample_ws("dirty-test")).await.unwrap();
assert!(mgr.is_dirty(), "create debería marcar dirty");
mgr.save_snapshot(&path).await.unwrap();
assert!(!mgr.is_dirty(), "save_snapshot debería limpiar dirty");
let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
// Esperamos un pelín para que mtime cambie si fuera re-escrito.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
// Segundo save sin mutación → skip.
mgr.save_snapshot(&path).await.unwrap();
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(mtime1, mtime2, "skip cuando clean — mtime no cambia");
}
#[tokio::test]
async fn snapshot_includes_saved_pipelines() {
use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec};