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 Sutichaykuna

boot: activate --from-select idempotente — el hook de apagado de arje-zero lo invoca incondicional

Contrato PLAN-KIKIN §9 (respondido en HANDOFF-KIKIN-DESDE-HAMMER.md): mirada
escribe /run/hammer/boot-select y dispara el reboot SIN salir; /run es tmpfs,
así que la selección se aplica en el apagado o nunca. arje-zero corre esto en
toda secuencia de apagado:
- sin fichero o vacío → no-op limpio (exit 0)
- con selección → activa (rollback E4) y CONSUME el canal; si falla, el fichero
  queda para diagnóstico
- --select parametrizable (testeable); 3 tests nuevos (d/e/f)
- 'boot menu' documentado como HARNESS de dev/VM: en producción mirada no sale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

e8e25fb88efa7dd61965eb1faf7f2a43eaa210e0 · ruwaq sergio · (3 killa ñawpaq) · tayta 6c864dcee · kay commitpi sach'ata qhaway

Semántico sayk'uy · 6

crates/hammer-cli/src/main.rs

  • silq'uyninpi t'ikrakurqan enum BootCmd
  • ukhunpi t'ikrakurqan fn main

2 qillqakuna · +101 −13

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index ab51598a..1f85091c 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -436,19 +436,31 @@ enum BootCmd {
     },
     /// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
     /// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
+    ///
+    /// `--from-select` es IDEMPOTENTE: sin fichero (o vacío) es un no-op limpio (exit 0), y tras
+    /// activar con éxito el fichero se consume. Contrato PLAN-KIKIN §9 (tawasuyu, 2026-07-16):
+    /// mirada escribe la selección y dispara el reinicio SIN salir (sostiene el DRM master);
+    /// arje-zero (PID1) invoca esto INCONDICIONAL en su secuencia de apagado, antes del reboot —
+    /// `/run` es tmpfs, así que la selección se aplica en el apagado o no se aplica nunca.
     Activate {
         /// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
         id: Option<String>,
-        /// Lee el id desde `/run/hammer/boot-select` (canal de vuelta de mirada).
+        /// Lee el id desde el canal de vuelta de mirada (no-op limpio si no existe o está vacío).
         #[arg(long)]
         from_select: bool,
+        /// Path del canal de vuelta que deja mirada.
+        #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)]
+        select: PathBuf,
     },
-    /// [ADR 0010 · glue de arranque] Orquesta el menú de boot completo: emite el grafo, lanza el
-    /// compositor (mirada) que lo dibuja sobre KMS, y activa el nodo que el usuario eligió. Pensado
-    /// para correr como root TEMPRANO en el arranque (desde el hook de init, junto a `hammer-recover`,
-    /// antes de incarnar arje-zero). Graceful: si no hay compositor instalado (p.ej. servidor headless)
-    /// emite el grafo y sigue el arranque normal. Contrato: el compositor renderiza el menú y, al
-    /// confirmar, escribe el id en `/run/hammer/boot-select` y termina; hammer entonces activa.
+    /// [ADR 0010 · HARNESS de dev/VM] Orquesta el menú de boot completo: emite el grafo, lanza el
+    /// compositor que lo dibuja sobre KMS, ESPERA su salida y activa lo que dejó en boot-select.
+    /// Sirve para validar el glue en OVMF/QEMU con un compositor stub que sí termina.
+    ///
+    /// EN PRODUCCIÓN NO SE USA (PLAN-KIKIN §4.1/§9, 2026-07-16): mirada real NUNCA sale — sostiene
+    /// el DRM master del greeter al escritorio para no re-modesetear (parpadeo). El camino real es:
+    /// arje-zero publica el grafo al arrancar (`hammer boot graph`), mirada escribe boot-select y
+    /// dispara el reboot por el bus de arje, y arje-zero corre `hammer boot activate --from-select`
+    /// en su secuencia de apagado.
     Menu {
         /// Comando del compositor a lanzar (se parte por espacios). El primer token es el binario.
         #[arg(long, default_value = "mirada-compositor --arranque")]
@@ -1243,20 +1255,42 @@ fn main() -> anyhow::Result<()> {
                         println!("  {} nodo(s); vivo: {vivo}", g.nodes.len());
                     }
                 }
-                BootCmd::Activate { id, from_select } => {
+                BootCmd::Activate { id, from_select, select } => {
                     let id = match (id, from_select) {
                         (Some(id), _) => id,
-                        (None, true) => std::fs::read_to_string(boot_graph::BOOT_SELECT_PATH)
-                            .map_err(|e| anyhow::anyhow!(
-                                "no pude leer {}: {e}", boot_graph::BOOT_SELECT_PATH))?
-                            .trim().to_string(),
+                        // Idempotente (contrato PLAN-KIKIN §9): arje-zero llama esto incondicional
+                        // en TODO apagado; sin selección no hay nada que aplicar y el apagado sigue.
+                        (None, true) => match std::fs::read_to_string(&select) {
+                            Ok(s) if s.trim().is_empty() => {
+                                println!(
+                                    "hammer boot activate: {} vacío — sin selección, no-op",
+                                    select.display()
+                                );
+                                return Ok(());
+                            }
+                            Ok(s) => s.trim().to_string(),
+                            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+                                println!(
+                                    "hammer boot activate: sin selección ({} no existe) — no-op",
+                                    select.display()
+                                );
+                                return Ok(());
+                            }
+                            Err(e) => anyhow::bail!("no pude leer {}: {e}", select.display()),
+                        },
                         (None, false) => anyhow::bail!(
                             "pasá un <id> o usá --from-select para leerlo de {}",
-                            boot_graph::BOOT_SELECT_PATH),
+                            select.display()),
                     };
                     let j = hammer_journal::Journal::open(&journal)?;
                     let graph_out = std::path::PathBuf::from(boot_graph::BOOT_GRAPH_PATH);
                     activate_and_report(&root, &state_root, &j, &id, &graph_out)?;
+                    // La selección ya se aplicó: consumir el canal para que una segunda corrida
+                    // (u otro apagado en la misma sesión) sea el no-op de arriba. Sólo en éxito —
+                    // si activate falló, el `?` ya salió y el fichero queda para diagnóstico.
+                    if from_select {
+                        let _ = std::fs::remove_file(&select);
+                    }
                 }
                 BootCmd::Menu { compositor, out, select } => {
                     // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen).
diff --git a/crates/hammer-cli/tests/boot_menu.rs b/crates/hammer-cli/tests/boot_menu.rs
index a0e7addf..a5f97856 100644
--- a/crates/hammer-cli/tests/boot_menu.rs
+++ b/crates/hammer-cli/tests/boot_menu.rs
@@ -83,3 +83,57 @@ fn c_seleccion_viaja_de_boot_select_a_activate() {
         "el fallo debe venir de activate (el id llegó): stderr={err}"
     );
 }
+
+// --- `hammer boot activate --from-select` idempotente (contrato PLAN-KIKIN §9) ---------------------
+// arje-zero lo invoca INCONDICIONAL en todo apagado: sin selección debe ser un no-op limpio (exit 0),
+// y el fichero sólo se consume si la activación tuvo éxito (si falla, queda para diagnóstico).
+
+fn activate_from_select(state: &Path, work: &Path, select: &Path) -> std::process::Output {
+    Command::new(BIN)
+        .args(["boot", "--state-root"])
+        .arg(state)
+        .arg("--journal")
+        .arg(work.join("journal"))
+        .args(["activate", "--from-select", "--select"])
+        .arg(select)
+        .output()
+        .expect("ejecutar hammer boot activate")
+}
+
+#[test]
+fn d_activate_from_select_sin_fichero_es_noop() {
+    let tmp = tempfile::tempdir().unwrap();
+    let state = tmp.path().join("state");
+    std::fs::create_dir_all(&state).unwrap();
+    let out = activate_from_select(&state, tmp.path(), &tmp.path().join("boot-select"));
+    assert!(out.status.success(), "sin fichero debe ser no-op exit 0: {out:?}");
+    let so = String::from_utf8_lossy(&out.stdout);
+    assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
+}
+
+#[test]
+fn e_activate_from_select_vacio_es_noop() {
+    let tmp = tempfile::tempdir().unwrap();
+    let state = tmp.path().join("state");
+    std::fs::create_dir_all(&state).unwrap();
+    let sel = tmp.path().join("boot-select");
+    std::fs::write(&sel, "\n").unwrap();
+    let out = activate_from_select(&state, tmp.path(), &sel);
+    assert!(out.status.success(), "fichero vacío debe ser no-op exit 0: {out:?}");
+    let so = String::from_utf8_lossy(&out.stdout);
+    assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
+}
+
+#[test]
+fn f_activate_fallido_no_consume_el_select() {
+    let tmp = tempfile::tempdir().unwrap();
+    let state = tmp.path().join("state");
+    std::fs::create_dir_all(&state).unwrap();
+    let sel = tmp.path().join("boot-select");
+    std::fs::write(&sel, "cafe1234deadbeef\n").unwrap();
+    // state_root vacío ⇒ no hay generación viva ⇒ activate falla…
+    let out = activate_from_select(&state, tmp.path(), &sel);
+    assert!(!out.status.success(), "con grafo vacío activate debe fallar: {out:?}");
+    // …y el canal queda intacto para diagnóstico (sólo se consume en éxito).
+    assert!(sel.exists(), "el boot-select NO debe consumirse cuando activate falla");
+}