La evidencia corre del lado del BUILD (hammer-agent no depende de hammer-build:
separación PROPONE/CONSTRUYE). Piezas:
- proto: RecipeInline lleva `evidence`; Event::BuildReady lleva
`verdict: Option<EvidenceVerdict>` (+ EvidenceCheckVerdict). Ambos con
skip_serializing_if ⇒ wire compatible con clientes pre-H1c.
- hammerd/bus: run_compile ejecuta la evidencia declarada tras sellar el
artefacto (run_evidence vía swm_bridge::recipe_from_source_patch) y adjunta
el veredicto en BuildReady. Si ni se pudo ejecutar ⇒ veredicto fallido
sintético (no pasa en silencio). El lab reporta; el gate vive en el agente.
- client: compile() devuelve CompileOutcome { artifact, verdict }.
- orchestrator: VERIFY lee el veredicto; si all_passed=false empuja
VerifyCheck::fail y ABORTA antes de hidratar (artefacto sellado, no toca el
sistema). Nuevo constructor VerifyCheck::fail.
Tests: roundtrip del veredicto en proto; stub-bus refleja evidencia→verdict;
nueva prueba de integración orchestrator_evidence_gate (veredicto fallido ⇒
run() aborta con "no se propone" y no hidrata). Suite completa verde (33 suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
5.4 KiB
Rust
159 lines
5.4 KiB
Rust
//! [H1c] Gate de evidencia en el orquestador contra un bus stub.
|
|
//!
|
|
//! El lab (aquí, un stub) ejecuta la evidencia tras sellar el artefacto y devuelve el veredicto
|
|
//! en `BuildReady`. Si algún check falla, `Orchestrator::run` debe ABORTAR con "no se propone" y
|
|
//! NO hidratar nada al `prefix` — el gate "falla ⇒ no se propone" del SDD 15 §H1.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::{UnixListener, UnixStream};
|
|
use std::path::PathBuf;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use hammer_agent::orchestrator::{ApplyTarget, CompileMode, Orchestrator};
|
|
use hammer_agent::MockTranslator;
|
|
use hammer_core::proto::{
|
|
Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, PROTOCOL_VERSION,
|
|
};
|
|
use hammer_core::swm::{Base, Mutation, Swm, SwmBuild};
|
|
use hammer_core::{BaseRef, Evidence, EvidenceCheck, EvidenceKind};
|
|
use std::collections::BTreeMap;
|
|
|
|
/// Bus stub que responde a `Compile` con `BuildReady` cuyo veredicto tiene `all_passed=false`.
|
|
fn start_failing_bus(sock: PathBuf) {
|
|
let _ = std::fs::remove_file(&sock);
|
|
let listener = UnixListener::bind(&sock).expect("bind stub");
|
|
thread::spawn(move || {
|
|
if let Some(Ok(stream)) = listener.incoming().next() {
|
|
thread::spawn(move || serve(stream));
|
|
}
|
|
});
|
|
}
|
|
|
|
fn serve(stream: UnixStream) {
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
let send = |w: &mut UnixStream, ev: &Event| {
|
|
w.write_all(serde_json::to_string(ev).unwrap().as_bytes()).unwrap();
|
|
w.write_all(b"\n").unwrap();
|
|
w.flush().unwrap();
|
|
};
|
|
|
|
let mut first = String::new();
|
|
reader.read_line(&mut first).unwrap();
|
|
let Ok(Command::Hello { .. }) = serde_json::from_str::<Command>(first.trim()) else {
|
|
return;
|
|
};
|
|
send(
|
|
&mut writer,
|
|
&Event::Welcome {
|
|
ver: PROTOCOL_VERSION,
|
|
caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
|
|
peer: Peer { uid: 1000, gid: 1000, pid: 1 },
|
|
},
|
|
);
|
|
|
|
loop {
|
|
let mut line = String::new();
|
|
match reader.read_line(&mut line) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(_) => {}
|
|
}
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(cmd) = serde_json::from_str::<Command>(line.trim()) else {
|
|
continue;
|
|
};
|
|
if let Command::Compile { recipe } = cmd {
|
|
send(
|
|
&mut writer,
|
|
&Event::BuildReady {
|
|
recipe: recipe.name.clone(),
|
|
artifact: "b3:stub-fail".into(),
|
|
verdict: Some(EvidenceVerdict {
|
|
all_passed: false,
|
|
max_level: None,
|
|
checks: vec![EvidenceCheckVerdict {
|
|
kind: EvidenceKind::CmdExit,
|
|
cmd: "grep --version".into(),
|
|
passed: false,
|
|
detail: "exit 7 ≠ esperado 0".into(),
|
|
}],
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn swm_with_evidence() -> Swm {
|
|
Swm {
|
|
swm_version: 1,
|
|
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
|
|
mutations: vec![Mutation::SourcePatch {
|
|
repo: Some("git://x/grep.git".into()),
|
|
commit: Some("abc".into()),
|
|
tarball: None,
|
|
sha256: None,
|
|
strip_components: None,
|
|
patch: None,
|
|
patch_url: None,
|
|
build: SwmBuild {
|
|
compiler: "zig-cc".into(),
|
|
target: "x86_64-linux-musl".into(),
|
|
link: "static".into(),
|
|
flags: vec![],
|
|
phases: Default::default(),
|
|
zig_version: None,
|
|
},
|
|
target_bin: "/usr/bin/grep".into(),
|
|
expected_hash: None,
|
|
deps: Default::default(),
|
|
evidence: Evidence {
|
|
checks: vec![EvidenceCheck {
|
|
kind: EvidenceKind::CmdExit,
|
|
cmd: "grep --version".into(),
|
|
expected_exit: 0,
|
|
expected_output: None,
|
|
}],
|
|
},
|
|
}],
|
|
signature: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn failing_evidence_aborts_and_does_not_hydrate() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
start_failing_bus(sock.clone());
|
|
for _ in 0..50 {
|
|
if sock.exists() {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
|
|
let prefix = d.path().join("prefix");
|
|
let store = d.path().join("store");
|
|
let base = BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() };
|
|
let t = MockTranslator::empty().with_intent("compile grep", swm_with_evidence());
|
|
let o = Orchestrator::new(
|
|
t,
|
|
base,
|
|
ApplyTarget::Prefix { prefix: prefix.clone() },
|
|
CompileMode::ViaBus { sock, store, timeout: Duration::from_secs(5) },
|
|
);
|
|
|
|
let err = o.run("compile grep").unwrap_err().to_string();
|
|
assert!(err.contains("no se propone"), "mensaje inesperado: {err}");
|
|
assert!(err.contains("evidencia") || err.contains("check(s)"), "{err}");
|
|
|
|
// El gate corta ANTES de hidratar: el target_bin no debe existir bajo el prefix.
|
|
assert!(
|
|
!prefix.join("usr/bin/grep").exists(),
|
|
"el binario se hidrató pese al fallo de evidencia"
|
|
);
|
|
}
|