From 6db3e3e302bd750d73eaa5062fd5c8ca726ddadd Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 19:41:51 +0000 Subject: [PATCH] Fase 5: BuildFailed.log_tail con la cola real del log del sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuando una fase de build falla, el lab ahora conserva las últimas 50 líneas del log y las entrega a la IA por el bus. - sandbox.rs: BuildFailure { reason, log_tail } (impl Error), wrap en Error::Other. Sandbox::run hace tee de stdout/stderr al padre (streaming en vivo intacto) y a un ring buffer acotado; al fallar, devuelve la cola en log_tail. spawn_pump + push_tail con tests. - BuildFailure::from_error(&Error) recupera la struct por downcast. - bus.rs run_compile: separa reason del log_tail real del compilador en vez de mandar e.to_string() y log_tail=None. Tests: push_tail (ring), roundtrip por downcast, none para errores planos. Firmas de build/build_source_patch sin cambios. 22 binarios verdes. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-build/src/lib.rs | 2 +- crates/hammer-build/src/sandbox.rs | 155 +++++++++++++++++++++++++++-- crates/hammerd/src/bus.rs | 10 +- docs/10-roadmap.md | 7 +- 4 files changed, 161 insertions(+), 13 deletions(-) diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index ba171d57..f2defe12 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -13,7 +13,7 @@ pub mod swm_bridge; pub use config::BuildConfig; pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile}; -pub use sandbox::Sandbox; +pub use sandbox::{BuildFailure, Sandbox}; pub use swm_bridge::build_source_patch; /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs index e96151c7..20e1d98b 100644 --- a/crates/hammer-build/src/sandbox.rs +++ b/crates/hammer-build/src/sandbox.rs @@ -8,8 +8,54 @@ //! - Salida: bind RW en `/out` (DESTDIR). //! - Red: aislada (`--unshare-all` incluye `--unshare-net`). +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; + +/// Cuántas líneas finales del log del sandbox conservamos para `BuildFailed.log_tail`. +const TAIL_LINES: usize = 50; + +/// Fallo de una fase de build, con la cola del log del lab si la pudimos capturar. Viaja +/// dentro de `hammer_core::Error::Other(anyhow)`; el bus la recupera por `downcast_ref` para +/// poblar `Event::BuildFailed.log_tail`. Ver `docs/07-agent-bus.md`. +#[derive(Debug, Clone)] +pub struct BuildFailure { + pub reason: String, + pub log_tail: Option, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.reason)?; + if let Some(tail) = &self.log_tail { + write!(f, "\n--- log (últimas líneas) ---\n{tail}")?; + } + Ok(()) + } +} + +impl std::error::Error for BuildFailure {} + +impl BuildFailure { + /// Recupera un `BuildFailure` de un error de hammer-core, si lo lleva dentro. El bus lo + /// usa para separar `reason` de `log_tail`. + pub fn from_error(e: &hammer_core::Error) -> Option<&BuildFailure> { + match e { + hammer_core::Error::Other(a) => a.downcast_ref::(), + _ => None, + } + } +} + +/// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua). +fn push_tail(buf: &mut VecDeque, line: String, cap: usize) { + if buf.len() == cap { + buf.pop_front(); + } + buf.push_back(line); +} #[derive(Debug, Clone)] pub struct Sandbox { @@ -25,23 +71,52 @@ pub struct Sandbox { } impl Sandbox { - /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hereda stdout/stderr al padre y - /// devuelve error con contexto si el exit code es != 0. + /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hace *tee* de stdout/stderr al padre + /// (para no perder el streaming en vivo) y a la vez retiene las últimas [`TAIL_LINES`] + /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en + /// `log_tail`, envuelto en `hammer_core::Error::Other`. pub fn run(&self, cmd: &str) -> hammer_core::Result<()> { let args = self.bwrap_args(cmd); tracing::debug!(?args, "bwrap"); - let status = Command::new("bwrap") + let mut child = Command::new("bwrap") .args(&args) .stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?; + + // Ring buffer compartido por ambos pumps: la cola combinada de stdout+stderr. + let tail = Arc::new(Mutex::new(VecDeque::::with_capacity(TAIL_LINES))); + let out = child.stdout.take().expect("stdout piped"); + let err = child.stderr.take().expect("stderr piped"); + let h_out = spawn_pump(out, tail.clone(), false); + let h_err = spawn_pump(err, tail.clone(), true); + + let status = child + .wait() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?; + // Aseguramos drenar ambos streams antes de leer la cola. + let _ = h_out.join(); + let _ = h_err.join(); + if !status.success() { - return Err(hammer_core::Error::Other(anyhow::anyhow!( + let tail_str = { + let g = tail.lock().expect("tail mutex"); + if g.is_empty() { + None + } else { + Some(g.iter().cloned().collect::>().join("\n")) + } + }; + let reason = format!( "build phase falló (exit {}): {cmd}", status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into()) - ))); + ); + return Err(hammer_core::Error::Other(anyhow::Error::new(BuildFailure { + reason, + log_tail: tail_str, + }))); } Ok(()) } @@ -114,6 +189,33 @@ impl Sandbox { } } +/// Lanza un thread que lee `reader` línea a línea, la reenvía al stream del padre +/// (`to_stderr` decide cuál) y la acumula en el ring `tail`. Devuelve el handle para join. +fn spawn_pump( + reader: R, + tail: Arc>>, + to_stderr: bool, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let buf = BufReader::new(reader); + for line in buf.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + // Tee en vivo al padre (sin bufferizar de más: el build puede tardar). + if to_stderr { + let _ = writeln!(std::io::stderr(), "{line}"); + } else { + let _ = writeln!(std::io::stdout(), "{line}"); + } + if let Ok(mut g) = tail.lock() { + push_tail(&mut g, line, TAIL_LINES); + } + } + }) +} + /// Errores tempranos: comprueba que las rutas base existen antes de invocar el build. pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> { // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve. @@ -132,3 +234,40 @@ pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_tail_keeps_last_n() { + let mut buf = VecDeque::new(); + for i in 0..10 { + push_tail(&mut buf, format!("line {i}"), 3); + } + let got: Vec<_> = buf.iter().cloned().collect(); + assert_eq!(got, vec!["line 7", "line 8", "line 9"]); + } + + #[test] + fn build_failure_roundtrips_through_core_error() { + // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast. + let bf = BuildFailure { + reason: "exit 2".into(), + log_tail: Some("undefined reference to `foo'".into()), + }; + let err = hammer_core::Error::Other(anyhow::Error::new(bf)); + let recovered = BuildFailure::from_error(&err).expect("debe recuperarse"); + assert_eq!(recovered.reason, "exit 2"); + assert_eq!( + recovered.log_tail.as_deref(), + Some("undefined reference to `foo'") + ); + } + + #[test] + fn from_error_none_for_plain_errors() { + let err = hammer_core::Error::Recipe("nope".into()); + assert!(BuildFailure::from_error(&err).is_none()); + } +} diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 25acdaa3..7a538f81 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -393,10 +393,16 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { }); } Err(e) => { + // Si el fallo trae la cola del log del lab, la separamos de la razón para que la + // IA pueda leer el error real del compilador, no sólo el mensaje de Rust. + let (reason, log_tail) = match hammer_build::sandbox::BuildFailure::from_error(&e) { + Some(bf) => (bf.reason.clone(), bf.log_tail.clone()), + None => (e.to_string(), None), + }; let _ = tx.send(Event::BuildFailed { recipe: name, - reason: e.to_string(), - log_tail: None, + reason, + log_tail, }); } } diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index d4650663..3dbf178f 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -101,8 +101,11 @@ pre-requisito de validación. `examples/agent-caps.toml`. El `default_policy` hardcoded queda como fallback. - [ ] `CRASHED` real (requiere supervisión de servicios, que llega con el init propio del track posterior). -- [ ] `BuildFailed.log_tail` con cola real del lab (hoy es `None`; el `reason` viene del - `Error` de Rust pero no se conserva el log textual del sandbox). +- [x] `BuildFailed.log_tail` con cola real del lab. `Sandbox::run` hace *tee* de + stdout/stderr (sigue viéndose en vivo) y retiene las últimas 50 líneas; al fallar una + fase devuelve `BuildFailure { reason, log_tail }` envuelto en `Error::Other`. El bus lo + recupera por `BuildFailure::from_error` (downcast) y separa `reason` del log textual del + compilador, para que la IA reaccione al error real, no sólo al mensaje de Rust. - **Hecho cuando:** un cliente externo dispara un build y recibe el evento de fin por el socket. ✅ Camino implementado (`Compile` → `BuildReady`/`BuildFailed`) y la maquinaria alrededor cubierta por `crates/hammerd/tests/bus_e2e.rs`: handshake con peer creds,