feat(shipote): CPU% + pipeline live-tail + replay por bytes (fase J)

- CPU% derivado server-side entre samples (WorkspaceState.last_cpu_sample).
  100% = 1 core saturado. Primer sample devuelve None (sin baseline).
- shipote pipeline run --tail: tras lanzar, suscribe al primer flow_socket
  y vuelca bytes hasta EOF. Auto-implica --tap.
- DiscernPolicy.replay_bytes: cap adicional por bytes para el replay
  buffer del FlowChannel. evict_for_incoming considera el chunk entrante
  para que post-push el buffer NUNCA exceda los caps.
- shipote-shell: stats history extiende sparkline con %CPU.

80 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 21, 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 00:57:04 +00:00
parent 36dac00c8d
commit d8727a3038
9 changed files with 216 additions and 37 deletions
+23 -4
View File
@@ -48,6 +48,9 @@ pub struct WorkspaceState {
pub root_card: Card,
pub commands: HashMap<Ulid, CommandState>,
pub started: Instant,
/// Última muestra de `(wall_instant, cpu_usec)` usada para calcular
/// `cpu_percent` en la próxima medición. None hasta el primer measure.
pub last_cpu_sample: Option<(Instant, u64)>,
}
#[derive(Debug, Clone)]
@@ -354,9 +357,12 @@ impl WorkspaceManager {
/// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
/// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
/// descendants).
///
/// `cpu_percent` se calcula entre samples consecutivos. Necesita ≥2
/// llamadas para tener un valor (la primera siempre retorna `None`).
pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
let g = self.inner.lock().await;
let ws = g.workspaces.get(&id)?;
let mut g = self.inner.lock().await;
let ws = g.workspaces.get_mut(&id)?;
let alive: Vec<i32> = ws
.commands
.values()
@@ -367,8 +373,6 @@ impl WorkspaceManager {
let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
None
} else {
// resolve_cgroup_path está en ente_incarnate, pero acá basta
// con el path absoluto bajo /sys/fs/cgroup. Resolución gruesa.
Some(std::path::PathBuf::from(format!(
"/sys/fs/cgroup{}",
ws.spec.soma.cgroup.path
@@ -376,6 +380,20 @@ impl WorkspaceManager {
};
let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
s.commands_total = total;
// CPU%: diff entre el sample actual y el previo, dividido por
// wall time. 100% = 1 core saturado. >100% = varios cores.
let now = Instant::now();
if let Some(cpu_now) = s.cpu_usec {
if let Some((prev_t, prev_cpu)) = ws.last_cpu_sample {
let dt_us = now.duration_since(prev_t).as_micros() as u64;
let d_cpu = cpu_now.saturating_sub(prev_cpu);
if dt_us > 0 {
s.cpu_percent = Some(100.0 * d_cpu as f32 / dt_us as f32);
}
}
ws.last_cpu_sample = Some((now, cpu_now));
}
Some(s)
}
@@ -403,6 +421,7 @@ impl WorkspaceManager {
root_card: card,
commands: HashMap::new(),
started: Instant::now(),
last_cpu_sample: None,
};
self.inner.lock().await.workspaces.insert(id, state);
info!(%id, ?ttl, "workspace created");