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
+66 -10
View File
@@ -103,6 +103,10 @@ enum PipeCmd {
/// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec.
#[arg(long = "var", value_parser = parse_kv)]
vars: Vec<(String, String)>,
/// Tras lanzar, suscribir al primer flow socket y volcar bytes
/// a stdout hasta EOF. Implica `--tap`.
#[arg(long)]
tail: bool,
},
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
Save {
@@ -130,6 +134,8 @@ enum PipeCmd {
tap: bool,
#[arg(long = "var", value_parser = parse_kv)]
vars: Vec<(String, String)>,
#[arg(long)]
tail: bool,
},
}
@@ -243,9 +249,14 @@ async fn main() -> Result<()> {
.cpu_usec
.map(|u| format!("{:.3} s", u as f64 / 1_000_000.0))
.unwrap_or_else(|| "".into());
let cpu_pct = info
.cpu_percent
.map(|p| format!("{p:.1} %"))
.unwrap_or_else(|| "— (esperando 2do sample)".into());
println!("rss: {rss}");
println!("rss_peak: {peak}");
println!("cpu: {cpu}");
println!("cpu_pct: {cpu_pct}");
println!("source: {}", info.source);
println!("uptime: {} ms", info.uptime_ms);
}
@@ -287,18 +298,28 @@ async fn main() -> Result<()> {
}
}
Cmd::Pipeline(PipeCmd::Run { spec, tap, vars }) => {
Cmd::Pipeline(PipeCmd::Run { spec, tap, vars, tail }) => {
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
// --tail implica --tap (no hay flow socket sin tap).
let effective_tap = tap || tail;
let resp = round_trip(
&mut stream,
Request::PipelineRun {
spec: p,
tap,
tap: effective_tap,
vars: vars.into_iter().collect(),
},
)
.await?;
print_pipeline_started(resp)?;
let socket = print_pipeline_started_returning_socket(resp)?;
if tail {
if let Some(sock) = socket {
eprintln!("--- tailing {} ---", sock.display());
tail_socket(&sock).await?;
} else {
eprintln!("--tail: no hay flow socket disponible");
}
}
}
Cmd::Pipeline(PipeCmd::Save { name, spec }) => {
@@ -351,17 +372,26 @@ async fn main() -> Result<()> {
}
}
Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars }) => {
Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars, tail }) => {
let effective_tap = tap || tail;
let resp = round_trip(
&mut stream,
Request::PipelineRunSaved {
name,
tap,
tap: effective_tap,
vars: vars.into_iter().collect(),
},
)
.await?;
print_pipeline_started(resp)?;
let socket = print_pipeline_started_returning_socket(resp)?;
if tail {
if let Some(sock) = socket {
eprintln!("--- tailing {} ---", sock.display());
tail_socket(&sock).await?;
} else {
eprintln!("--tail: no hay flow socket disponible");
}
}
}
Cmd::Commands { workspace } => {
@@ -511,29 +541,55 @@ fn print_unexpected(r: &Response) {
eprintln!("unexpected response: {r:?}");
}
fn print_pipeline_started(resp: Response) -> Result<()> {
/// Imprime el resultado del launch del pipeline y retorna el path del
/// primer flow socket (si hay), útil para `--tail`.
fn print_pipeline_started_returning_socket(resp: Response) -> Result<Option<PathBuf>> {
match resp {
Response::PipelineStarted { pipeline, command_pids, edges } => {
println!("pipeline {pipeline}");
for (label, pid) in command_pids {
println!(" {:<20} pid={pid}", label);
}
let mut first_socket: Option<PathBuf> = None;
if !edges.is_empty() {
println!("edges:");
for e in edges {
for e in &edges {
println!(
" {}.{}{}.{} ty={:?} mime={:?} conf={:.2}",
e.from_label, e.from_output, e.to_label, e.to_input,
e.ty, e.mime, e.confidence,
);
if first_socket.is_none() {
first_socket = e.flow_socket.clone();
}
}
}
Ok(())
Ok(first_socket)
}
Response::Error { message } => Err(anyhow!(message)),
other => {
print_unexpected(&other);
Ok(())
Ok(None)
}
}
}
async fn tail_socket(socket: &std::path::Path) -> Result<()> {
use tokio::io::AsyncReadExt;
// Pequeña ventana de retry — el daemon retiene el flow channel
// antes de retornar, así que en la práctica ya está bindeado.
let mut s = UnixStream::connect(socket)
.await
.with_context(|| format!("connect {}", socket.display()))?;
let mut buf = [0u8; 4096];
loop {
let n = s.read(&mut buf).await?;
if n == 0 {
break;
}
use std::io::Write;
let _ = std::io::stdout().write_all(&buf[..n]);
let _ = std::io::stdout().flush();
}
Ok(())
}
+1
View File
@@ -344,6 +344,7 @@ async fn dispatch(
rss_bytes: s.rss_bytes,
rss_peak_bytes: s.rss_peak_bytes,
cpu_usec: s.cpu_usec,
cpu_percent: s.cpu_percent,
source: s.source,
uptime_ms: s.uptime_ms,
},
+10 -5
View File
@@ -389,19 +389,24 @@ impl Render for Shell {
.map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect())
.unwrap_or_default();
let spark = sparkline(&rss_series, STATS_HISTORY_LEN);
let (rss_now, peak) = history
.and_then(|h| h.back())
.map(|s| (s.rss_bytes.unwrap_or(0), s.rss_peak_bytes.unwrap_or(0)))
.unwrap_or((0, 0));
let latest = history.and_then(|h| h.back());
let (rss_now, peak, cpu_pct) = latest
.map(|s| (
s.rss_bytes.unwrap_or(0),
s.rss_peak_bytes.unwrap_or(0),
s.cpu_percent.unwrap_or(0.0),
))
.unwrap_or((0, 0, 0.0));
let rss_mb = rss_now as f64 / 1024.0 / 1024.0;
let peak_mb = peak as f64 / 1024.0 / 1024.0;
format!(
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M",
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M {:>5.1}%cpu",
&w.id.to_string()[20..],
w.label,
spark,
rss_mb,
peak_mb,
cpu_pct,
)
})
.collect();