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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

feat(bus): B.2 sink del CRASHED — hammerd::crashes traduce eventos de arje

Nuevo módulo hammerd::crashes: traduce la señal de ciclo de vida normalizada
(fuente = arje-bus BusEvent) a proto::Event::Crashed y la bombea al EventBus →
agent.sock (capa de IA). Traducción + bucle de bombeo desacoplados del
transporte y testeados (3 tests). Resta sólo el adaptador de transporte
arje-bus ↔ hammerd. Roadmap B.2 actualizado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

7c099afe4f940b9b6db3b5192660428779479039 · por Sergio · (hace 4 meses) · padre e9f535a1b · ver el árbol en este commit

Delta semántico · 9

crates/hammerd/src/crashes.rs

crates/hammerd/src/main.rs

4 archivos · +149 −4

diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs
index ea4f2834..7afa8afa 100644
--- a/crates/hammer-core/src/proto.rs
+++ b/crates/hammer-core/src/proto.rs
@@ -117,8 +117,10 @@ pub enum Event {
     },
     /// La línea fue enviada al FIFO de control.
     InitAck { cmd: String },
-    /// Un servicio supervisado murió. (Fase 5 sólo lo declara; la supervisión real llega
-    /// con el init propio del track posterior.)
+    /// Un servicio supervisado murió. La supervisión real la provee el init propio
+    /// (arje, PID 1): difunde `arje_bus::BusEvent::EnteCrashed` y el sink
+    /// `hammerd::crashes` lo traduce a este evento. Falta sólo el adaptador de
+    /// transporte que conecta ambos buses (ver `hammerd::crashes`).
     Crashed { service: String, code: i32 },
     /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
     /// registrarlo en el diario.
diff --git a/crates/hammerd/src/crashes.rs b/crates/hammerd/src/crashes.rs
new file mode 100644
index 00000000..a6673823
--- /dev/null
+++ b/crates/hammerd/src/crashes.rs
@@ -0,0 +1,131 @@
+//! Puente de crashes: la **fuente real** del `Event::Crashed` del bus de agente.
+//!
+//! Hasta ahora `Event::Crashed` sólo lo declaraba el protocolo y lo disparaban los
+//! tests: la Fase 5 difirió la "supervisión real" al init propio del track posterior.
+//! Ese init es **arje** (ver `docs/adr/0007-arje-como-init-propio.md`): como PID 1
+//! supervisa cada Ente, detecta su muerte en `on_death` y la difunde por su bus como
+//! `BusEvent::EnteCrashed` (crate `arje-bus`, `BusRequest::Subscribe` + `BusPayload::Event`).
+//!
+//! Este módulo es el **sink** de ese flujo (B.2 "exponer el CRASHED a la capa de IA"):
+//! traduce la señal de ciclo de vida normalizada al vocabulario del bus de agente de
+//! hammer (`Event::Crashed`) y la bombea al [`EventBus`] → toda conexión del
+//! `/run/agent.sock` (la capa de IA) la recibe.
+//!
+//! ## Frontera de acoplamiento
+//!
+//! La **traducción** y el **bucle de bombeo** viven acá y son agnósticos del transporte
+//! —se testean sin un init vivo—. Lo único que falta para cerrar B.2 end-to-end es el
+//! *adaptador de transporte*: un thread que conecte al socket de arje
+//! (`$ARJE_BUS_SOCK`), mande un `Subscribe` y reenvíe cada `BusEvent` a un
+//! [`std::sync::mpsc::Sender<Lifecycle>`] hacia [`pump`]. Ese adaptador es lo que
+//! decide cómo los dos repos comparten el wire (dep directa a `arje-bus` vs. crate de
+//! proto compartido vs. relectura del frame postcard) — decisión registrada en
+//! `docs/09-trust-model.md` / el PLAN de arje, y validable sólo contra un init real.
+//!
+//! Las APIs quedan listas (`pub`) aunque el transporte aún no las invoque: ese es el
+//! patrón "vocabulario declarado, flujo por cablear" que arje-zero usa en su `events.rs`.
+#![allow(dead_code)]
+
+use std::sync::mpsc::Receiver;
+
+use hammer_core::proto::Event;
+
+use crate::events::EventBus;
+
+/// Señal de ciclo de vida normalizada desde el supervisor (arje), independiente del
+/// wire concreto para no acoplar `hammerd` al crate `arje-bus`. El adaptador de
+/// transporte construye estos valores a partir de `arje_bus::BusEvent`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Lifecycle {
+    /// Identidad humana del Ente que cambió de estado (su `label` en arje). Es lo que
+    /// la capa de IA verá como `service`.
+    pub service: String,
+    pub status: LifeStatus,
+}
+
+/// Estado terminal/transicional de un Ente, ya normalizado.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LifeStatus {
+    /// Terminó de forma anómala. `code` es el exit-code; para muertes por señal el
+    /// adaptador usa la convención de shell `128 + signum` para que sea siempre ≠ 0.
+    Crashed { code: i32 },
+    /// arje programó un reinicio tras backoff. Observabilidad — el `proto` actual no
+    /// tiene evento dedicado, así que no se traduce (todavía).
+    Restarting { delay_ms: u64 },
+    /// Terminó limpio (exit 0) y no se reinicia. Sin evento en el `proto` actual.
+    Exited,
+}
+
+/// Traduce una señal de ciclo de vida al evento del bus de agente. Devuelve `None`
+/// para los estados que el `proto` actual no expone (reinicios / salidas limpias):
+/// hoy sólo el crash es accionable por la IA.
+pub fn to_event(life: &Lifecycle) -> Option<Event> {
+    match &life.status {
+        LifeStatus::Crashed { code } => Some(Event::Crashed {
+            service: life.service.clone(),
+            code: *code,
+        }),
+        LifeStatus::Restarting { .. } | LifeStatus::Exited => None,
+    }
+}
+
+/// Bucle de bombeo: drena señales de la fuente (el adaptador de transporte de arje) y
+/// publica las accionables en el `EventBus`. Bloquea hasta que la fuente se cierra;
+/// pensado para correr en su propio thread, como los demás subsistemas de `hammerd`.
+pub fn pump(source: Receiver<Lifecycle>, bus: EventBus) {
+    for life in source {
+        if let Some(ev) = to_event(&life) {
+            bus.publish(&ev);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn crash_traduce_a_event_crashed() {
+        let life = Lifecycle {
+            service: "web".into(),
+            status: LifeStatus::Crashed { code: 139 }, // 128 + SIGSEGV
+        };
+        assert_eq!(
+            to_event(&life),
+            Some(Event::Crashed { service: "web".into(), code: 139 })
+        );
+    }
+
+    #[test]
+    fn restarting_y_exited_no_producen_evento() {
+        let r = Lifecycle { service: "x".into(), status: LifeStatus::Restarting { delay_ms: 100 } };
+        let e = Lifecycle { service: "x".into(), status: LifeStatus::Exited };
+        assert_eq!(to_event(&r), None);
+        assert_eq!(to_event(&e), None);
+    }
+
+    #[test]
+    fn pump_publica_solo_los_crashes_en_el_bus() {
+        let bus = EventBus::new();
+        let sub = bus.subscribe();
+
+        let (tx, rx) = std::sync::mpsc::channel();
+        // Mezcla: un exited (descartado), un crash (publicado), un restarting (descartado).
+        tx.send(Lifecycle { service: "a".into(), status: LifeStatus::Exited }).unwrap();
+        tx.send(Lifecycle { service: "b".into(), status: LifeStatus::Crashed { code: 1 } }).unwrap();
+        tx.send(Lifecycle { service: "c".into(), status: LifeStatus::Restarting { delay_ms: 5 } }).unwrap();
+        drop(tx); // cierra la fuente → pump termina.
+
+        pump(rx, bus);
+
+        // Sólo el crash de "b" llegó al suscriptor.
+        match sub.try_recv().expect("debía haber un Crashed") {
+            Event::Crashed { service, code } => {
+                assert_eq!(service, "b");
+                assert_eq!(code, 1);
+            }
+            other => panic!("esperaba Crashed, fue {other:?}"),
+        }
+        assert!(sub.try_recv().is_err(), "no debía publicarse nada más");
+    }
+}
diff --git a/crates/hammerd/src/main.rs b/crates/hammerd/src/main.rs
index f9a9a55f..1e989c33 100644
--- a/crates/hammerd/src/main.rs
+++ b/crates/hammerd/src/main.rs
@@ -14,6 +14,7 @@ use clap::Parser;
 
 mod bus;
 mod control;
+mod crashes;
 mod events;
 mod watcher;
 
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index d1821303..4cd1f8f5 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -285,8 +285,19 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume
   `SWAP_BWRAP=1`. **Pendiente:** correr in-VM acumulando los swaps
   (`KVM=1 MEM=24576 SWAP_MAKE=1 SWAP_BUSYBOX=1 SWAP_LINUX_HEADERS=1 SWAP_BWRAP=1 ./scripts/selfhost-verify.sh`)
   para el `✓ REPRODUCIBLE`, y seguir con la última pieza: rust/llvm (la grande — ya hay infra de deps).
-- ⏭️ **También pendiente (Stage 1):** bus único (B.2: exponer el `CRASHED` a la capa de IA) y
-  atestación arje (A1/A2).
+- 🔨 **B.2 — `CRASHED` real a la capa de IA (en progreso):** el `Event::Crashed` del bus de
+  agente ya no es sólo declarativo. **Fuente (lado arje, ✅):** `arje-bus` ganó
+  `BusRequest::Subscribe` + `BusPayload::Event(BusEvent)`; arje-zero difunde en `on_death`
+  `EnteCrashed{id,label,status}` / `EnteRestarting{delay_ms}` / `EnteExited` a las conexiones
+  suscritas, purgando las muertas (4 tests). **Sink (lado hammer, ✅):** `hammerd::crashes`
+  traduce la señal normalizada → `Event::Crashed` y la bombea al `EventBus` →
+  `/run/agent.sock` (3 tests). **Falta el último tramo:** el *adaptador de transporte* —un
+  thread que conecte a `$ARJE_BUS_SOCK`, mande `Subscribe` y reenvíe cada `BusEvent` a
+  `crashes::pump` (vía `BusClient::{subscribe,next_event}`)— y la decisión de cómo los dos
+  repos comparten el wire (dep directa a `arje-bus` vs. proto compartido vs. relectura del
+  frame postcard). Es el único paso que necesita un init vivo para validarse end-to-end.
+- ⏭️ **También pendiente (Stage 1):** atestación arje (A1/A2 — ya cableada en el repo arje,
+  resta sólo enchufar su veredicto de boot a este roadmap).
 
 ## Notas de entorno
 - Desarrollo principal: laptop del autor.