> 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…
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>
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");
+}