gioser

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 1184Shell 70Rust 62Markdown 501503 Sutichaykuna

hammer-upgrade: journal de intención + recover idempotente (E4 endurecimiento #4a)

El apply escribe el plan completo a pending.json ANTES de proyectar y lo limpia
al commitear; un corte a media proyección deja pending.json + FHS a medias. La
proyección se hizo re-entrante (project_plan, backups idempotentes via
backup_existing_once que nunca pisa el original) ⇒ recover COMPLETA (roll-forward,
re-verifica of_tree) o DESHACE (roll-back: restaura backups, borra la gen a
medias, current->padre). apply se niega con PendingExists si hay intento; status
lo avisa. CLI: hammer upgrade recover [--rollback]. 5 tests (corte a media
proyeccion -> ambos modos) + ejercicio en upgrade-e2e-test.sh. 14 tests verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

01b3a225b24754394e809e68042318e67361be29 · ruwaq sergio · (4 killa ñawpaq) · tayta fd09e842b · kay commitpi sach'ata qhaway

Semántico sayk'uy · 21

crates/hammer-cli/src/main.rs

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

crates/hammer-upgrade/src/lib.rs

4 qillqakuna · +398 −59

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index c6e31cbf..d93032de 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -275,6 +275,12 @@ enum UpgradeCmd {
     },
     /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
     Rollback,
+    /// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
+    Recover {
+        /// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
+        #[arg(long)]
+        rollback: bool,
+    },
     /// Muestra la generación viva y el historial.
     Status,
     /// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
@@ -865,7 +871,21 @@ fn main() -> anyhow::Result<()> {
                     }
                     println!("  {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
                 }
+                UpgradeCmd::Recover { rollback } => {
+                    let j = hammer_journal::Journal::open(&journal)?;
+                    let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
+                    if !r.recovered {
+                        println!("no hay apply interrumpido — nada que recuperar");
+                    } else if r.rolled_forward {
+                        println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
+                    } else {
+                        println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
+                    }
+                }
                 UpgradeCmd::Status => {
+                    if let Some(p) = hammer_upgrade::pending(&state_root)? {
+                        println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
+                    }
                     let cur = hammer_upgrade::current(&state_root)?;
                     match cur {
                         Some(id) => println!("generación viva: {id}"),
diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs
index 10092880..7a7d13cc 100644
--- a/crates/hammer-upgrade/src/lib.rs
+++ b/crates/hammer-upgrade/src/lib.rs
@@ -58,6 +58,11 @@ pub enum Error {
     Verify { dir: String, want: String, got: String },
     #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
     NothingToRollback,
+    #[error(
+        "upgrade: hay un apply interrumpido pendiente (generación {generation}) — corré \
+         `hammer upgrade recover` (completa) o `hammer upgrade recover --rollback` (deshace) antes de seguir"
+    )]
+    PendingExists { generation: u64 },
     #[error("upgrade: {0}")]
     Other(String),
 }
@@ -114,6 +119,17 @@ pub struct ApplyReport {
     pub already_current: bool,
 }
 
+/// Reporte de un [`recover`].
+#[derive(Debug, Clone, Default)]
+pub struct RecoverReport {
+    /// `false` si no había nada que recuperar (no había intento pendiente).
+    pub recovered: bool,
+    /// `true` = se completó el apply (roll-forward); `false` = se deshizo (roll-back).
+    pub rolled_forward: bool,
+    /// Generación afectada por el intento pendiente.
+    pub generation: u64,
+}
+
 /// Reporte de un [`rollback`].
 #[derive(Debug, Clone, Default)]
 pub struct RollbackReport {
@@ -141,6 +157,33 @@ fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
     generations_dir(state_root).join(id.to_string()).join("manifest.json")
 }
 
+/// El **journal de intención**: el plan de un apply en curso. Existe sólo entre el inicio de la
+/// proyección y el commit; si sobrevive a un reinicio, un apply quedó a medias y hay que [`recover`].
+fn pending_path(state_root: &Path) -> PathBuf {
+    state_root.join("pending.json")
+}
+
+/// Lee el plan pendiente (apply interrumpido), o `None` si no hay ninguno.
+pub fn pending(state_root: &Path) -> Result<Option<GenerationManifest>> {
+    let p = pending_path(state_root);
+    if !p.is_file() {
+        return Ok(None);
+    }
+    Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
+}
+
+fn write_pending(state_root: &Path, manifest: &GenerationManifest) -> Result<()> {
+    write_atomic(&pending_path(state_root), &serde_json::to_vec_pretty(manifest)?)
+}
+
+fn clear_pending(state_root: &Path) -> Result<()> {
+    match std::fs::remove_file(pending_path(state_root)) {
+        Ok(()) => Ok(()),
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(e) => Err(e.into()),
+    }
+}
+
 /// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
 pub fn current(state_root: &Path) -> Result<Option<u64>> {
     let f = current_file(state_root);
@@ -329,6 +372,12 @@ pub fn apply(
         }
     }
 
+    // Un apply previo interrumpido deja un `pending.json` ⇒ exigimos `recover` explícito antes de tocar
+    // nada (no pisamos un FHS a medias). El recover lo completa (roll-forward) o lo deshace (roll-back).
+    if let Some(p) = pending(state_root)? {
+        return Err(Error::PendingExists { generation: p.id });
+    }
+
     // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
     if let Some(cur) = current(state_root)? {
         if let Some(m) = get(state_root, cur)? {
@@ -338,69 +387,136 @@ pub fn apply(
         }
     }
 
+    // --- PLAN completo ANTES de tocar nada: entradas del árbol nuevo + ficheros a retirar + op/path ---
     let new_entries = enumerate_tree(&tree_path)?;
     let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
     let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
-
-    // Ficheros que la generación viva aportaba y el árbol nuevo NO ⇒ se retiran.
-    let prev_files: Vec<PathBuf> = match current(state_root)? {
+    let prev_id = current(state_root)?;
+    let prev_files: Vec<PathBuf> = match prev_id {
         Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
         None => Vec::new(),
     };
     let removed_files: Vec<PathBuf> =
         prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
 
-    let id = next_id(state_root)?;
-    let gen_base = generations_dir(state_root).join(id.to_string());
-    let backup_root = gen_base.join("backup");
-    std::fs::create_dir_all(&backup_root)?;
-
-    let mut report = ApplyReport { generation: id, ..Default::default() };
-    let mut changes: Vec<Change> = Vec::new();
-
-    // (3)+(4) proyectar el árbol nuevo.
+    let mut changes: Vec<Change> = Vec::with_capacity(new_entries.len() + removed_files.len());
     for e in &new_entries {
-        let dst = target_root.join(&e.rel);
-        let existed = std::fs::symlink_metadata(&dst).is_ok();
-        if existed {
-            backup_existing(&dst, &backup_root.join(&e.rel))?;
-        }
-        project_entry(&tree_path, e, &dst)?;
-        let op = if existed { ChangeOp::Replaced } else { ChangeOp::Added };
-        match op {
-            ChangeOp::Added => report.added.push(e.rel.clone()),
-            ChangeOp::Replaced => report.replaced.push(e.rel.clone()),
-            ChangeOp::Removed => unreachable!(),
-        }
-        changes.push(Change { path: e.rel.clone(), op });
-        journal_change(journal, tree_dir, &dst, op);
+        let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
+        changes.push(Change {
+            path: e.rel.clone(),
+            op: if existed { ChangeOp::Replaced } else { ChangeOp::Added },
+        });
     }
-
-    // Retirar los sobrantes del árbol previo.
     for rel in &removed_files {
-        let dst = target_root.join(rel);
-        if std::fs::symlink_metadata(&dst).is_ok() {
-            backup_existing(&dst, &backup_root.join(rel))?;
-            remove_path(&dst)?;
-            report.removed.push(rel.clone());
+        if std::fs::symlink_metadata(target_root.join(rel)).is_ok() {
             changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
-            journal_change(journal, tree_dir, &dst, ChangeOp::Removed);
         }
     }
 
-    // (5) manifiesto + avance de `current` (commit-point).
+    let id = next_id(state_root)?;
     let manifest = GenerationManifest {
         id,
         tree_dir: tree_dir.to_string(),
         tree_content,
-        parent: current(state_root)?,
+        parent: prev_id,
         created_at: now_secs(),
         files: new_files,
         changes,
     };
+
+    let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
+    std::fs::create_dir_all(&backup_root)?;
+
+    // (INTENTO) escribir el plan ANTES de proyectar: el commit-point sigue siendo `current`, pero ahora
+    // un corte a media proyección es recuperable — `recover` re-ejecuta el plan (idempotente) o lo deshace.
+    write_pending(state_root, &manifest)?;
+
+    // (PROYECTAR) ejecutar el plan: backups idempotentes (no pisan el original) + escritura temporal+rename.
+    project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
+
+    // (COMMIT) manifiesto definitivo + avance de `current` + limpiar el intento.
     write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
     set_current(state_root, Some(id))?;
-    Ok(report)
+    clear_pending(state_root)?;
+    Ok(report_from_changes(id, &manifest.changes))
+}
+
+/// Construye el [`ApplyReport`] a partir de la lista de cambios del plan.
+fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
+    let mut r = ApplyReport { generation: id, ..Default::default() };
+    for c in changes {
+        match c.op {
+            ChangeOp::Added => r.added.push(c.path.clone()),
+            ChangeOp::Replaced => r.replaced.push(c.path.clone()),
+            ChangeOp::Removed => r.removed.push(c.path.clone()),
+        }
+    }
+    r
+}
+
+/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
+/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
+/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
+/// **re-entrante**: correrlo dos veces deja el mismo resultado.
+fn project_plan(
+    tree_path: &Path,
+    manifest: &GenerationManifest,
+    target_root: &Path,
+    backup_root: &Path,
+    journal: Option<&Journal>,
+) -> Result<()> {
+    let entries = enumerate_tree(tree_path)?;
+    let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
+        entries.iter().map(|e| (&e.rel, e)).collect();
+    for ch in &manifest.changes {
+        let dst = target_root.join(&ch.path);
+        match ch.op {
+            ChangeOp::Added | ChangeOp::Replaced => {
+                if ch.op == ChangeOp::Replaced {
+                    backup_existing_once(&dst, &backup_root.join(&ch.path))?;
+                }
+                if let Some(e) = by_rel.get(&ch.path) {
+                    project_entry(tree_path, e, &dst)?;
+                }
+            }
+            ChangeOp::Removed => {
+                backup_existing_once(&dst, &backup_root.join(&ch.path))?;
+                remove_path(&dst)?;
+            }
+        }
+        journal_change(journal, &manifest.tree_dir, &dst, ch.op);
+    }
+    Ok(())
+}
+
+/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
+/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
+fn undo_changes(
+    manifest: &GenerationManifest,
+    target_root: &Path,
+    backup_root: &Path,
+    journal: Option<&Journal>,
+    report_restored: &mut Vec<PathBuf>,
+    report_deleted: &mut Vec<PathBuf>,
+) -> Result<()> {
+    for ch in manifest.changes.iter().rev() {
+        let dst = target_root.join(&ch.path);
+        match ch.op {
+            ChangeOp::Added => {
+                if std::fs::symlink_metadata(&dst).is_ok() {
+                    remove_path(&dst)?;
+                    report_deleted.push(ch.path.clone());
+                    journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
+                }
+            }
+            ChangeOp::Replaced | ChangeOp::Removed => {
+                restore_from_backup(&backup_root.join(&ch.path), &dst)?;
+                report_restored.push(ch.path.clone());
+                journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
+            }
+        }
+    }
+    Ok(())
 }
 
 // ---------------------------------------------------------------------------------------------------
@@ -420,29 +536,67 @@ pub fn rollback(
     let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
 
     let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
+    undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
+    set_current(state_root, m.parent)?;
+    Ok(report)
+}
 
-    // Deshacer en orden inverso al apply (los Added al final, por si un Replaced creó su dir padre).
-    for ch in m.changes.iter().rev() {
-        let dst = target_root.join(&ch.path);
-        match ch.op {
-            ChangeOp::Added => {
-                if std::fs::symlink_metadata(&dst).is_ok() {
-                    remove_path(&dst)?;
-                    report.deleted.push(ch.path.clone());
-                    journal_change(journal, &m.tree_dir, &dst, ChangeOp::Removed);
-                }
-            }
-            ChangeOp::Replaced | ChangeOp::Removed => {
-                let bak = backup_root.join(&ch.path);
-                restore_from_backup(&bak, &dst)?;
-                report.restored.push(ch.path.clone());
-                journal_change(journal, &m.tree_dir, &dst, ChangeOp::Replaced);
-            }
+/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
+///
+/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
+///   — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
+///   el plan registró (defensa ante un store que cambió bajo los pies).
+/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
+///   generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
+///
+/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
+pub fn recover(
+    store_root: &Path,
+    target_root: &Path,
+    state_root: &Path,
+    journal: Option<&Journal>,
+    rollback: bool,
+) -> Result<RecoverReport> {
+    let manifest = match pending(state_root)? {
+        Some(m) => m,
+        None => return Ok(RecoverReport::default()),
+    };
+    let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
+
+    if rollback {
+        let (mut restored, mut deleted) = (Vec::new(), Vec::new());
+        undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
+        // El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
+        if current(state_root)? == Some(manifest.id) {
+            set_current(state_root, manifest.parent)?;
         }
+        let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
+        clear_pending(state_root)?;
+        return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
     }
 
-    set_current(state_root, m.parent)?;
-    Ok(report)
+    // roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
+    let store = Store::open(store_root)?;
+    let tree_path = store.root().join(&manifest.tree_dir);
+    if !tree_path.is_dir() {
+        return Err(Error::Other(format!(
+            "recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
+            manifest.tree_dir
+        )));
+    }
+    let got = ArtifactHash::of_tree(&tree_path)?;
+    if got.as_str() != manifest.tree_content {
+        return Err(Error::Verify {
+            dir: manifest.tree_dir.clone(),
+            want: manifest.tree_content.clone(),
+            got: got.as_str().to_string(),
+        });
+    }
+    project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
+    write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
+    set_current(state_root, Some(manifest.id))?;
+    clear_pending(state_root)?;
+    Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
 }
 
 // ---------------------------------------------------------------------------------------------------
@@ -497,6 +651,19 @@ fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
     Ok(())
 }
 
+/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
+/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
+/// proyección sin sobrescribir el backup con la versión ya proyectada).
+fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
+    if std::fs::symlink_metadata(bak).is_ok() {
+        return Ok(()); // ya respaldado
+    }
+    if std::fs::symlink_metadata(src).is_err() {
+        return Ok(()); // nada que respaldar
+    }
+    backup_existing(src, bak)
+}
+
 /// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
 /// (idempotencia: ya restaurado o nunca existió).
 fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
@@ -767,6 +934,134 @@ mod tests {
         assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
     }
 
+    /// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
+    /// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
+    fn interrupt_apply(
+        store: &Path,
+        tree_dir: &str,
+        target_root: &Path,
+        state_root: &Path,
+        partial: usize,
+    ) -> GenerationManifest {
+        let store_h = Store::open(store).unwrap();
+        let tree_path = store_h.root().join(tree_dir);
+        let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
+        let entries = enumerate_tree(&tree_path).unwrap();
+        let mut changes = Vec::new();
+        for e in &entries {
+            let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
+            changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
+        }
+        let id = next_id(state_root).unwrap();
+        let manifest = GenerationManifest {
+            id, tree_dir: tree_dir.to_string(), tree_content,
+            parent: current(state_root).unwrap(), created_at: 0,
+            files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
+        };
+        let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
+        std::fs::create_dir_all(&backup_root).unwrap();
+        write_pending(state_root, &manifest).unwrap();
+        // proyectar SÓLO `partial` entradas (corte a media proyección).
+        for e in entries.iter().take(partial) {
+            let dst = target_root.join(&e.rel);
+            if std::fs::symlink_metadata(&dst).is_ok() {
+                backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
+            }
+            project_entry(&tree_path, e, &dst).unwrap();
+        }
+        manifest
+    }
+
+    #[test]
+    fn apply_refuses_when_a_pending_apply_exists() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let v1 = seal_tree(&store, &h64("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
+        interrupt_apply(&store, &v1, &root, &state, 1);
+        // con un intento pendiente, apply se NIEGA (exige recover).
+        let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
+        assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
+        assert!(pending(&state).unwrap().is_some());
+    }
+
+    #[test]
+    fn recover_roll_forward_completes_interrupted_apply() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        std::fs::create_dir_all(&root).unwrap();
+        write(&root.join("etc/keep"), b"PRE");
+        let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
+            write(&w.join("usr/bin/ls"), b"NEW-ls");
+            write(&w.join("usr/bin/cat"), b"NEW-cat");
+            write(&w.join("usr/bin/grep"), b"NEW-grep");
+        });
+        // corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
+        interrupt_apply(&store, &v1, &root, &state, 1);
+        assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
+        assert!(pending(&state).unwrap().is_some());
+
+        let r = recover(&store, &root, &state, None, false).unwrap();
+        assert!(r.recovered && r.rolled_forward && r.generation == 1);
+        // el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
+        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
+        assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
+        assert_eq!(current(&state).unwrap(), Some(1));
+        assert!(pending(&state).unwrap().is_none());
+        // y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
+        rollback(&root, &state, None).unwrap();
+        assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
+        assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
+    }
+
+    #[test]
+    fn recover_roll_back_undoes_interrupted_apply() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        std::fs::create_dir_all(&root).unwrap();
+        write(&root.join("usr/bin/ls"), b"ORIG-ls");   // existirá ⇒ Replaced (con backup)
+        let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
+            write(&w.join("usr/bin/ls"), b"NEW-ls");
+            write(&w.join("usr/bin/new"), b"NEW-only"); // Added
+        });
+        // corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
+        interrupt_apply(&store, &v1, &root, &state, 1);
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
+
+        let r = recover(&store, &root, &state, None, true).unwrap();
+        assert!(r.recovered && !r.rolled_forward);
+        // el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
+        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
+        assert!(!root.join("usr/bin/new").exists());
+        assert_eq!(current(&state).unwrap(), None);
+        assert!(pending(&state).unwrap().is_none());
+        assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
+    }
+
+    #[test]
+    fn recover_is_noop_without_pending() {
+        let tmp = tempfile::tempdir().unwrap();
+        let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
+        assert!(!r.recovered);
+    }
+
+    #[test]
+    fn apply_clears_pending_on_success() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let v1 = seal_tree(&store, &h64("e4"), "v1", |w| write(&w.join("a"), b"A"));
+        apply(&store, &v1, None, &root, &state, None).unwrap();
+        assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
+    }
+
     #[test]
     fn prune_removes_orphans_but_keeps_live_chain() {
         let tmp = tempfile::tempdir().unwrap();
diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md
index c33d400a..2013d0f4 100644
--- a/docs/13-release-engineering.md
+++ b/docs/13-release-engineering.md
@@ -83,8 +83,16 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m
   prune [--keep N]` / `hammer_upgrade::prune`): borra las generaciones que el rollback ya no alcanza
   (huérfanas tras un rollback — las de la [cadena viva](#) siempre se conservan); `--keep N` además
   recorta la cadena a sus N más nuevas (limita la profundidad de rollback, como `delete-generations` en
-  NixOS). **Endurecimiento pendiente:** journal de *intención* + replay para sobrevivir un kernel-panic a
-  media escritura; cableado del init de boot para que el rollback sea seleccionable al arranque.
+  NixOS). **Journal de intención + replay ✅** (`hammer upgrade recover [--rollback]` /
+  `hammer_upgrade::{pending,recover}`): el apply escribe el **plan completo** a `pending.json` ANTES de
+  proyectar y lo limpia al commitear; si un corte/reinicio lo interrumpe, `pending.json` sobrevive y el
+  FHS quedó a medias. La proyección es **re-entrante** (`project_plan`, backups *idempotentes* —
+  `backup_existing_once` nunca pisa el original capturado), así `recover` **completa** (roll-forward:
+  re-ejecuta el plan + commitea, re-verificando el `of_tree`) o **deshace** (roll-back: restaura backups,
+  borra la generación a medias, `current`→padre). `apply` se **niega** si hay intento pendiente (exige
+  recover explícito); `status` lo avisa. 5 tests (corte a media proyección → ambos modos) + ejercicio en
+  `upgrade-e2e-test.sh`. **Endurecimiento pendiente:** cableado del init de boot para que rollback/recover
+  sea seleccionable al arranque (necesita el binario `hammer` en el producto).
 - **E5 — ISO/medio de arranque: ✅ primer corte** (`recipes/xorriso.toml` + `scripts/iso-image.sh` +
   `scripts/iso-boot-test.sh`). Medio **live** que arranca ENTERO desde RAM: GRUB (El Torito) carga
   kernel + initramfs desde el ISO 9660, el kernel desempaqueta el rootfs del producto a un tmpfs y corre
diff --git a/scripts/upgrade-e2e-test.sh b/scripts/upgrade-e2e-test.sh
index f556520e..59daad7e 100755
--- a/scripts/upgrade-e2e-test.sh
+++ b/scripts/upgrade-e2e-test.sh
@@ -108,5 +108,21 @@ echo "  ok: prune $GENS_ANTES→$GENS_DESPUES generación(es)"
 # el árbol vivo (gen 3 = v1) sigue intacto tras el prune.
 assert "$ROOT/usr/bin/ls" "LS-v1" "post-prune"
 
+echo "==> recover: simulamos un apply interrumpido plantando un pending.json y lo recuperamos"
+# El recover real se prueba a fondo en los tests unitarios (corte a media proyección). Acá ejercitamos
+# el CABLEADO del CLI contra artefactos reales: plantamos el manifiesto de la gen viva como intento
+# pendiente; status debe avisar y recover (roll-forward, idempotente) completarlo y limpiarlo.
+CUR=$(cat "$STATE/current")
+cp "$STATE/generations/$CUR/manifest.json" "$STATE/pending.json"
+up status | grep -q "apply interrumpido pendiente" || { echo "FALLO: status no avisó del intento" >&2; exit 1; }
+echo "  ok: status avisó del intento pendiente"
+up recover | grep -q "COMPLETADO" || { echo "FALLO: recover no completó" >&2; exit 1; }
+[ ! -e "$STATE/pending.json" ] || { echo "FALLO: recover no limpió el intento" >&2; exit 1; }
+assert "$ROOT/usr/bin/ls" "LS-v1" "post-recover"
+echo "  ok: recover completó el intento y lo limpió"
+# segundo recover = no-op.
+up recover | grep -q "nada que recuperar" || { echo "FALLO: recover no es no-op sin intento" >&2; exit 1; }
+echo "  ok: recover sin intento es no-op"
+
 echo
-echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune"
+echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune + recover"