tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

Fase 5: BuildFailed.log_tail con la cola real del log del sandbox

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) <noreply@anthropic.com>

6db3e3e302bd750d73eaa5062fd5c8ca726ddadd · ruwaq Sergio · (4 killa ñawpaq) · tayta 42288230f · kay commitpi sach'ata qhaway

Semántico sayk'uy · 12

crates/hammer-build/src/sandbox.rs

crates/hammerd/src/bus.rs

4 qillqakuna · +161 −13

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<String>,
+}
+
+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::<BuildFailure>(),
+            _ => None,
+        }
+    }
+}
+
+/// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua).
+fn push_tail(buf: &mut VecDeque<String>, 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::<String>::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::<Vec<_>>().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<R: std::io::Read + Send + 'static>(
+    reader: R,
+    tail: Arc<Mutex<VecDeque<String>>>,
+    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<Event>) {
             });
         }
         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,