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:
co-authored by
Claude Opus 4.8
parent
42288230f9
commit
6db3e3e302
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user