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 Definitions

⎇ main crates/takana-agent/tests/repair_loop.rs Rust · 252 lines · 8.6 KB History Who touched what raw

takana etapa 4: los 10 crates de librería y el CLI pasan a takana-* 24bcf1783 · Sergio ·

1//! E2E del bucle de auto-reparación (Fase 6 cont.).
2//!
3//! Stub multi-conexión del bus: cada conexión hace handshake y luego empuja eventos
4//! pre-programados por el test. El orquestador abre una nueva conexión por intento
5//! (lo cuál nos da puntos limpios para "este intento crashea, este intento no").
6
7use std::io::{BufRead, BufReader, Write};
8use std::os::unix::net::{UnixListener, UnixStream};
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11use std::thread;
12use std::time::Duration;
13
14use takana_core::proto::{Cap, Command, Event, Peer, PROTOCOL_VERSION};
15use takana_core::swm::{Base, Mutation};
16use takana_core::{BaseRef, Swm};
17
18use takana_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy};
19use takana_agent::{MockTranslator, Orchestrator};
20
21/// El plan por conexión: lista de eventos a empujar tras el Welcome. El stub itera
22/// por las conexiones en orden y consume un slot del plan por cada una.
23type ConnectionPlan = Vec<Event>;
24
25fn start_stub_bus(sock: PathBuf, plans: Vec<ConnectionPlan>) {
26    let _ = std::fs::remove_file(&sock);
27    let listener = UnixListener::bind(&sock).expect("bind stub");
28    let plans = Arc::new(Mutex::new(plans.into_iter()));
29    thread::Builder::new()
30        .name("stub-bus-repair".into())
31        .spawn(move || {
32            for incoming in listener.incoming() {
33                let Ok(stream) = incoming else { continue };
34                let next_plan = {
35                    let mut p = plans.lock().unwrap();
36                    p.next()
37                };
38                let plan = next_plan.unwrap_or_default();
39                thread::spawn(move || stub_serve(stream, plan));
40            }
41        })
42        .unwrap();
43}
44
45fn stub_serve(mut stream: UnixStream, plan: ConnectionPlan) {
46    // Handshake: leer Hello, responder Welcome.
47    let mut reader = BufReader::new(stream.try_clone().unwrap());
48    let mut first = String::new();
49    if reader.read_line(&mut first).is_err() || first.trim().is_empty() {
50        return;
51    }
52    let cmd: Command = match serde_json::from_str(first.trim()) {
53        Ok(c) => c,
54        Err(_) => return,
55    };
56    let Command::Hello { .. } = cmd else { return };
57    let welcome = Event::Welcome {
58        ver: PROTOCOL_VERSION,
59        caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
60        peer: Peer { uid: 1000, gid: 1000, pid: 1 },
61    };
62    let line = serde_json::to_string(&welcome).unwrap();
63    let _ = stream.write_all(line.as_bytes());
64    let _ = stream.write_all(b"\n");
65    let _ = stream.flush();
66
67    // Empujamos cada evento del plan inmediatamente. El cliente los recibirá vía
68    // `drain_async()` o `next_async()`. Los eventos del plan se ponen como asíncronos
69    // (Crashed/Modified) porque el orquestador no envía comandos durante wait_for_crash.
70    for ev in plan {
71        let line = serde_json::to_string(&ev).unwrap();
72        if stream.write_all(line.as_bytes()).is_err() {
73            return;
74        }
75        let _ = stream.write_all(b"\n");
76        let _ = stream.flush();
77    }
78
79    // Mantenemos abierto hasta que el cliente cierre.
80    let mut buf = String::new();
81    loop {
82        buf.clear();
83        match reader.read_line(&mut buf) {
84            Ok(0) => return,
85            Ok(_) => {}
86            Err(_) => return,
87        }
88    }
89}
90
91fn wait_for_sock(sock: &std::path::Path) {
92    for _ in 0..200 {
93        if sock.exists() {
94            return;
95        }
96        std::thread::sleep(Duration::from_millis(10));
97    }
98    panic!("stub bus no bindeó {}", sock.display());
99}
100
101fn baseref() -> BaseRef {
102    BaseRef {
103        distro_version: "2026-06-06".into(),
104        pins: Default::default(),
105    }
106}
107
108fn swm_touch(path: &str) -> Swm {
109    Swm {
110        swm_version: 1,
111        base: Base { distro_version: "2026-06-06".into(), pins: Default::default() },
112        mutations: vec![Mutation::FileDrop {
113            path: path.into(),
114            // hash de "X" (un byte 0x58) — calculado con BLAKE3.
115            content_hash: takana_core::ArtifactHash::of_inputs(&[b"X"]).as_str().to_string(),
116            content_b64: Some("WA==".into()),
117            content_url: None,
118        }],
119        signature: None,
120    }
121}
122
123#[test]
124fn repair_loop_reacts_to_crashed_event() {
125    let d = tempfile::tempdir().unwrap();
126    let sock = d.path().join("repair.sock");
127    let prefix = d.path().join("root");
128    std::fs::create_dir_all(&prefix).unwrap();
129
130    // Plan:
131    //   conn 1 (tras primer apply): empujamos Crashed → el bucle debe reintentar.
132    //   conn 2 (tras segundo apply): empujamos nada → la ventana expira → estabilizado.
133    let plans = vec![
134        vec![Event::Crashed { service: "web".into(), code: 137 }],
135        vec![],
136    ];
137    start_stub_bus(sock.clone(), plans);
138    wait_for_sock(&sock);
139
140    // Catálogo: la intención original toca /flag.original; la reparación toca /flag.repair.
141    let t = MockTranslator::empty()
142        .with_intent("install web", swm_touch("/flag.original"))
143        .with_intent(
144            "repair service web crashed code 137",
145            swm_touch("/flag.repair"),
146        );
147
148    let orch = Orchestrator::new(
149        t,
150        baseref(),
151        ApplyTarget::Prefix { prefix: prefix.clone() },
152        CompileMode::Skip,
153    );
154
155    let policy = RepairPolicy {
156        max_attempts: 3,
157        crash_window: Duration::from_millis(300),
158        event_sock: Some(sock.clone()),
159        ..RepairPolicy::default()
160    };
161
162    let proposal = orch
163        .run_with_repair("install web", &policy)
164        .expect("run_with_repair OK");
165
166    // Debe haber dos intentos: el original (con crash) y la reparación (estabilizada).
167    assert_eq!(proposal.repair_chain.len(), 2, "{:?}", proposal.repair_chain);
168    assert_eq!(proposal.repair_chain[0].intent, "install web");
169    assert_eq!(
170        proposal.repair_chain[0].triggered_crash.as_ref().map(|c| (c.service.as_str(), c.code)),
171        Some(("web", 137))
172    );
173    assert_eq!(
174        proposal.repair_chain[1].intent,
175        "repair service web crashed code 137"
176    );
177    assert!(proposal.repair_chain[1].triggered_crash.is_none());
178
179    // Ambos efectos en disco son visibles (cada apply re-rooteó bajo prefix).
180    assert!(prefix.join("flag.original").exists());
181    assert!(prefix.join("flag.repair").exists());
182}
183
184#[test]
185fn repair_loop_caps_attempts_when_crashes_persist() {
186    let d = tempfile::tempdir().unwrap();
187    let sock = d.path().join("persistent.sock");
188    let prefix = d.path().join("root");
189    std::fs::create_dir_all(&prefix).unwrap();
190
191    // Plan: TODAS las conexiones empujan Crashed. El bucle nunca estabiliza y debe
192    // parar al llegar a `max_attempts`.
193    let plans = vec![
194        vec![Event::Crashed { service: "x".into(), code: 1 }],
195        vec![Event::Crashed { service: "x".into(), code: 1 }],
196    ];
197    start_stub_bus(sock.clone(), plans);
198    wait_for_sock(&sock);
199
200    // El catálogo retorna el MISMO swm para la intención original y la reparada para
201    // que ambos intentos puedan resolverse. El propósito del test es el límite de intentos.
202    let swm = swm_touch("/flag.x");
203    let t = MockTranslator::empty()
204        .with_intent("intent A", swm.clone())
205        .with_intent("repair service x crashed code 1", swm);
206
207    let orch = Orchestrator::new(
208        t,
209        baseref(),
210        ApplyTarget::Prefix { prefix: prefix.clone() },
211        CompileMode::Skip,
212    );
213
214    let policy = RepairPolicy {
215        max_attempts: 2,
216        crash_window: Duration::from_millis(200),
217        event_sock: Some(sock.clone()),
218        ..RepairPolicy::default()
219    };
220
221    let proposal = orch
222        .run_with_repair("intent A", &policy)
223        .expect("run_with_repair devuelve OK aún con crashes persistentes");
224
225    assert_eq!(proposal.repair_chain.len(), 2);
226    // Ambos intentos crashearon ⇒ el último entry refleja el crash pendiente para
227    // que el humano lo vea.
228    assert!(proposal.repair_chain.last().unwrap().triggered_crash.is_some());
229}
230
231#[test]
232fn repair_loop_without_event_sock_runs_once() {
233    let d = tempfile::tempdir().unwrap();
234    let prefix = d.path().join("root");
235    std::fs::create_dir_all(&prefix).unwrap();
236    let t = MockTranslator::empty().with_intent("only one", swm_touch("/flag"));
237    let orch = Orchestrator::new(
238        t,
239        baseref(),
240        ApplyTarget::Prefix { prefix: prefix.clone() },
241        CompileMode::Skip,
242    );
243    let policy = RepairPolicy {
244        max_attempts: 5,
245        crash_window: Duration::from_millis(200),
246        event_sock: None,
247        ..RepairPolicy::default()
248    };
249    let p = orch.run_with_repair("only one", &policy).unwrap();
250    assert_eq!(p.repair_chain.len(), 1);
251    assert!(p.repair_chain[0].triggered_crash.is_none());
252}