feat(shipote): pipeline backoff + quota card + logs follow (fase M)

- PipelineSpec.restart_backoff_ms + restart_max_backoff_ms + restart_max:
  backoff exponencial entre relaunches (anti-thrash). take_pending_restarts
  aplica restart_max (0 = infinito); excedido = supervisor descartado con
  warning. Daemon hace tokio::sleep(backoff) antes del relaunch y escala
  current_backoff x2 hasta el cap.
- shipote-shell card "Quota breaches": probe extiende con WorkspaceQuota
  por workspace. Color rojo si hay breaches, verde si no.
- shipote logs --follow: poll cada 200ms al daemon, imprime suffix nuevo
  hasta que el comando termine. Sin cambios al protocolo. Best-effort:
  si el ring rota más rápido que el poll, se pierden bytes.

83 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 24, 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 10:34:27 +00:00
parent 4c9d1b4c1d
commit c3f9c9e36a
7 changed files with 236 additions and 38 deletions
+75 -18
View File
@@ -69,6 +69,9 @@ enum Cmd {
/// Stream a leer: stdout | stderr | both.
#[arg(long, default_value = "both")]
stream: String,
/// Seguir el log en vivo (poll cada 200ms hasta que el comando termine).
#[arg(short = 'f', long)]
follow: bool,
},
/// Pipeline DAG con flujo tipado.
@@ -457,28 +460,82 @@ async fn main() -> Result<()> {
}
}
Cmd::Logs { workspace, command, tail, stream: which_stream } => {
Cmd::Logs { workspace, command, tail, stream: which_stream, follow } => {
let ws = parse_ws_id(&workspace)?;
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
let resp = round_trip(
&mut stream,
Request::CommandLogs {
workspace: ws,
command: cmd_id,
tail_bytes: tail,
stream: which_stream,
},
)
.await?;
match resp {
Response::CommandLogs { bytes } => {
// stdout raw, sin decoding — el log puede tener bytes binarios.
use std::io::Write;
let _ = std::io::stdout().write_all(&bytes);
if !follow {
let resp = round_trip(
&mut stream,
Request::CommandLogs {
workspace: ws,
command: cmd_id,
tail_bytes: tail,
stream: which_stream,
},
)
.await?;
match resp {
Response::CommandLogs { bytes } => {
use std::io::Write;
let _ = std::io::stdout().write_all(&bytes);
let _ = std::io::stdout().flush();
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
} else {
// Follow mode: poll cada 200ms. Mantenemos el último buffer
// visto; cada round imprimimos el delta (suffix nuevo).
// Limitación: si el ring rota más rápido que el poll, perdemos
// bytes — pero el comportamiento es "best effort".
use std::io::Write;
let mut prev: Vec<u8> = Vec::new();
loop {
let resp = round_trip(
&mut stream,
Request::CommandLogs {
workspace: ws,
command: cmd_id,
tail_bytes: 0,
stream: which_stream.clone(),
},
)
.await?;
let bytes = match resp {
Response::CommandLogs { bytes } => bytes,
Response::Error { message } => return Err(anyhow!(message)),
other => {
print_unexpected(&other);
break;
}
};
// Imprimir suffix nuevo si bytes es extension de prev.
if bytes.len() >= prev.len() && bytes[..prev.len()] == prev[..] {
let _ = std::io::stdout().write_all(&bytes[prev.len()..]);
} else {
// Ring rotó — reset y print todo.
let _ = std::io::stdout().write_all(&bytes);
}
let _ = std::io::stdout().flush();
prev = bytes;
// Si el comando terminó, salir tras un último read.
let list_resp = round_trip(
&mut stream,
Request::CommandList { workspace: ws },
)
.await?;
let mut still_alive = false;
if let Response::CommandList { items } = list_resp {
if let Some(c) = items.iter().find(|c| c.id == cmd_id) {
still_alive = c.alive;
}
}
if !still_alive {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}