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>
This commit is contained in:
Sergio
2026-06-10 19:41:51 +00:00
co-authored by Claude Opus 4.8
parent 42288230f9
commit 6db3e3e302
4 changed files with 161 additions and 13 deletions
+1 -1
View File
@@ -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.
+147 -8
View File
@@ -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());
}
}
+8 -2
View File
@@ -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,
});
}
}
+5 -2
View File
@@ -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,