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 1411Shell 141Markdown 85Rust 842918 Definitions

paso 6: ADR 0012 decidido e implementado — la carrera del árbol de fuentes se cierra con un lock por receta

Estaba PENDIENTE desde el 2026-07-22 con un «no implementar sin resolver el dilema». Se resolvió
midiendo las tres cosas que el propio ADR pedía, y la PRIMERA estaba mal planteada:

1. «¿Cuánto serializa un lock por árbol?» NADA. El ADR suponía que un consumidor toca el árbol de su
   dep, y no lo toca: recibe el ARTEFACTO sellado como capa overlay. Dos recetas distintas nunca
   comparten su árbol propio ⇒ no se serializan. Y los locks NO SE ANIDAN, porque `build` construye
   las deps ANTES de tocar su propio árbol ⇒ no puede haber deadlock, sin ordenar nada.
2. «¿Cuánto cuesta re-extraer y re-vendorear?» (opción B) 2,4 %: medido en el build de ntpd-rs,
   fetch 4,0 s + cargo vendor 5,4 s contra 6m25s de compilación. El tiempo no era el problema de B;
   el disco sí — ese árbol pesa 707 MiB por build concurrente.
3. «¿Espacio pico de caché + copia?» (opción C) 12,4 GiB en 46 árboles, los mayores de 2,4-2,5 G. C
   duplica lo que esté en vuelo y NO ahorra el vendoreo, que muta y va en la copia.

DECISIÓN: opción A precisada. Lock exclusivo por NOMBRE DE RECETA (el mirror git también es estado
compartido y su clave es el nombre), tomado en fetch y sostenido por RAII hasta el seal
(fetch::GuardaArbol), más re-chequeo de la tienda al obtenerlo: el que esperó sale por cache hit.
El fd no se hereda —Rust abre con O_CLOEXEC—, así que no puede repetirse el modo de fallo del
flock(1) de alrededor que dejó la granja parada hora y media en silencio.

VERIFICADO con dos builds concurrentes de la misma receta (A sella; B espera 1,27 s, obtiene el lock
y sale por cache hit; un artefacto, los dos rc=0) y con CONTROL: modelando la ventana real, dos
builds sobre el mismo árbol sin lock dan la firma exacta del apagón de KDE del 22/07 —
`.zwrap/cc AUSENTE, 0/400 fuentes`— y con lock los dos terminan OK. Más dos tests en el crate, uno
con su propio control (dos nombres distintos no se bloquean; si fallara, el lock serializaría builds
que no comparten nada).

No mueve ningún hash: el artefacto de la prueba salió idéntico al que el grafo registraba.

Habilita subir el paralelismo (JOBS=1 y el -P2 estaban por esta carrera) pero NO lo sube: eso es una
decisión de recursos — 6 cores y 16 G, y esta clase de máquina ya provocó un global_oom. El flock de
CLAUDE.md §1 sigue haciendo falta por otra razón: serializa contra los scripts de la granja.

0a4b3abb461b5e9b6a68cbb0508d2eb7899acae3 · by Sergio · (1 month ago) · parent 04a254a0e · browse the tree at this commit

Semantic delta · 11

crates/takana-build/src/fetch.rs

crates/takana-build/src/lib.rs

crates/takana-build/tests/fetch_tarball.rs

6 files · +380 −11

diff --git a/CLAUDE.md b/CLAUDE.md
index abdbaab2..13f10002 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,19 @@ el fetch concurrente de otra receta. Es el **ADR 0012**, sin decidir; hasta que
 (lock por árbol / árbol privado / caché inmutable + copia), serializar es la única mitigación
 correcta. Por eso el worker corre con `JOBS=1`.
 
+✅ **ACTUALIZADO 2026-09-22 — el ADR 0012 está DECIDIDO e IMPLEMENTADO, y la carrera ya no existe.**
+`takana build` toma un lock exclusivo POR RECETA en el `fetch` y lo sostiene hasta el `seal`
+(`fetch::GuardaArbol`), con re-chequeo de la tienda al obtenerlo: el que espera sale por cache hit.
+No serializa la granja —dos recetas distintas no comparten su árbol propio, y un consumidor recibe el
+ARTEFACTO de su dep, no su árbol— ni puede deadlockear, porque las deps se construyen ANTES de tomar
+el lock propio (los locks no se anidan). Verificado con dos builds concurrentes y con un control que
+reproduce la firma del apagón (`.zwrap/cc AUSENTE, 0/400 fuentes` sin lock; OK con él).
+
+⇒ **El `flock` de abajo SIGUE HACIENDO FALTA, pero por otra razón**: serializa contra
+`farm-worker-loop.sh` y `campana-deuda.sh`, que es control de RECURSOS (RAM, disco), no de
+corrupción. Y `JOBS=1` ya no es una necesidad de corrección: subirlo es una decisión de recursos —
+el worker son 6 cores / 16 G y esta clase de máquina ya provocó un `global_oom`.
+
 ⚠ **Y usar `flock -o`, no `flock` a secas — esto es medido, no teórico (2026-09-08).** El lock lo
 sostiene la *descripción de fichero abierta*, y los hijos la HEREDAN: si un nieto se fuga, el lock
 queda tomado para siempre aunque el `flock` haya terminado hace rato. Pasó: dos `firefox` colgados de
diff --git a/crates/takana-build/src/fetch.rs b/crates/takana-build/src/fetch.rs
index e2e5ea71..a389bafc 100644
--- a/crates/takana-build/src/fetch.rs
+++ b/crates/takana-build/src/fetch.rs
@@ -16,13 +16,117 @@ use std::process::{Command, Stdio};
 
 use takana_core::{Recipe, SourceKind};
 
-/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
-pub fn fetch(recipe: &Recipe, work_root: &Path) -> takana_core::Result<PathBuf> {
-    match recipe.source.kind()? {
+/// Exclusión mutua del ÁRBOL DE FUENTES de una receta — **la decisión del [ADR 0012]**.
+///
+/// ## Qué carrera cierra
+///
+/// El árbol se nombra de forma determinista y SIN componente por-constructor
+/// (`<nombre>-<sha|commit>`), así que dos procesos que construyan la MISMA receta resuelven al mismo
+/// directorio: uno hace `remove_dir_all` mientras el otro corre `tar -x` o compila, y el árbol queda
+/// roto **para siempre** (el siguiente intento vuelve a chocar). Medido a escala: al invalidar
+/// `libdrm`, ~93 de 205 recetas KDE murieron con `/src/.zwrap/cc is not a full path to an existing
+/// compiler tool` — el wrapper de zig que la receta deja EN EL ÁRBOL, barrido por el fetch de otra.
+///
+/// ## Por qué un lock por árbol NO serializa la granja (lo que el ADR temía)
+///
+/// El ADR descartaba a medias esta opción porque «el grafo es un diamante que converge ⇒ puede
+/// degenerar en serializar casi todo». **Eso supone que un consumidor toca el árbol de su dep, y no
+/// lo hace.** El orden de `build` es, y esto es lo que decide la cuestión:
+///
+/// 1. `store.has(h)` → si está, vuelve sin tocar NINGÚN árbol (cache hit).
+/// 2. `materialize_build_deps` → construye las deps (recursivo) **y termina**.
+/// 3. `fetch` del árbol PROPIO → patches → vendor → compilar → sellar.
+///
+/// De ahí salen las dos propiedades que hacen viable la opción A:
+///
+/// * **Dos recetas distintas nunca comparten su árbol propio** ⇒ sus builds no se serializan entre
+///   sí. Lo único que se serializa es construir la MISMA receta dos veces a la vez, que es
+///   precisamente lo que no se debe hacer — y el perdedor del lock, al re-chequear la tienda,
+///   encuentra el artefacto ya sellado y sale por cache hit en vez de reconstruirlo.
+/// * **Los locks NO SE ANIDAN**: cuando un build toma el lock de su árbol, sus deps ya están
+///   construidas y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin
+///   ordenar nada ni detectar ciclos.
+///
+/// ## El grano es el NOMBRE de la receta, no el árbol
+///
+/// Porque el mirror git (`repos/<nombre>.git`) es estado compartido también, y su clave es sólo el
+/// nombre: dos builds de la misma receta a DISTINTO commit tienen árboles distintos y el mismo
+/// mirror, y un `clone --mirror` sobre un directorio que existe falla. Con el lock por nombre, el
+/// mirror, el árbol, los patches, el vendoreo y la compilación quedan todos dentro.
+///
+/// ## El fd no se hereda, y eso es deliberado
+///
+/// `CLAUDE.md` documenta cómo un `flock` se queda tomado para siempre: **el lock lo sostiene la
+/// descripción de fichero, y los hijos la heredan**; un nieto fugado dejó a la granja sin compilar
+/// hora y media, en silencio. Acá no puede pasar: Rust abre todo con `O_CLOEXEC`, así que ni `tar`,
+/// ni `git`, ni `bwrap`, ni un nieto suyo heredan este descriptor. Es la ventaja de hacerlo en el
+/// proceso en vez de con el `flock(1)` de alrededor.
+///
+/// [ADR 0012]: ../../../docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
+#[derive(Debug)]
+pub struct GuardaArbol {
+    _fd: std::fs::File,
+    nombre: String,
+}
+
+impl Drop for GuardaArbol {
+    fn drop(&mut self) {
+        // El `flock` se suelta al cerrar el fd; el log existe para que una espera larga tenga un
+        // final visible en la salida del build.
+        tracing::debug!(receta = %self.nombre, "árbol: lock liberado");
+    }
+}
+
+/// Toma el lock exclusivo del árbol de `nombre`. Bloquea si otro build lo tiene, **avisando**: una
+/// espera muda se lee como un cuelgue, que es el modo de fallo que este repo ya pagó con `flock`.
+pub fn tomar_guarda(work_root: &Path, nombre: &str) -> takana_core::Result<GuardaArbol> {
+    use std::os::unix::io::AsRawFd;
+    let dir = work_root.join("locks");
+    std::fs::create_dir_all(&dir)?;
+    // El fichero de lock va FUERA de `sources/`: dentro lo barrería el `remove_dir_all` del propio
+    // fetch, que es exactamente el mecanismo que estamos cerrando.
+    let ruta = dir.join(format!("{nombre}.lock"));
+    let f = std::fs::OpenOptions::new()
+        .create(true)
+        .read(true)
+        .write(true)
+        .truncate(false)
+        .open(&ruta)?;
+    let fd = f.as_raw_fd();
+    // Primero sin bloquear, para poder DECIR que se espera y por qué.
+    let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
+    if rc != 0 {
+        tracing::info!(
+            receta = %nombre,
+            "árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta (ADR 0012)"
+        );
+        let rc2 = unsafe { libc::flock(fd, libc::LOCK_EX) };
+        if rc2 != 0 {
+            return Err(takana_core::Error::Other(anyhow::anyhow!(
+                "no pude tomar el lock del árbol {}: {}",
+                ruta.display(),
+                std::io::Error::last_os_error()
+            )));
+        }
+        tracing::info!(receta = %nombre, "árbol: lock obtenido tras esperar");
+    }
+    Ok(GuardaArbol { _fd: f, nombre: nombre.to_string() })
+}
+
+/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta **y la guarda**.
+///
+/// La guarda se devuelve —en vez de tomarse y soltarse acá dentro— porque el árbol se sigue mutando
+/// DESPUÉS del fetch (patches, vendoreo) y se bindea para compilar: un lock que sólo cubriera la
+/// extracción no cierra la carrera (es el §El dilema del ADR 0012). Quien llama la mantiene viva
+/// hasta sellar; devolverla en la tupla hace **imposible** olvidarla.
+pub fn fetch(recipe: &Recipe, work_root: &Path) -> takana_core::Result<(PathBuf, GuardaArbol)> {
+    let guarda = tomar_guarda(work_root, &recipe.name)?;
+    let arbol = match recipe.source.kind()? {
         SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
         SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
         SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root),
-    }
+    }?;
+    Ok((arbol, guarda))
 }
 
 /// Materializa el árbol de una receta DERIVADA: copia `<dir-de-la-receta>/<dir>` al workspace.
@@ -843,6 +947,56 @@ pub fn apply_patches(recipe: &Recipe, tree: &Path) -> takana_core::Result<()> {
 mod tests {
     use super::*;
 
+    /// El invariante del ADR 0012: **dos guardas del mismo nombre no coexisten**.
+    ///
+    /// Se prueba con dos hilos y no con dos procesos porque `flock` se asocia a la DESCRIPCIÓN de
+    /// fichero, no al proceso: dos `open()` distintos —aunque sean del mismo proceso— se excluyen
+    /// igual. El control es el segundo caso: nombres DISTINTOS no se bloquean, que es lo que hace
+    /// que esto no serialice la granja.
+    #[test]
+    fn dos_guardas_del_mismo_arbol_se_excluyen() {
+        use std::sync::atomic::{AtomicBool, Ordering};
+        use std::sync::Arc;
+        let tmp = tempfile::tempdir().unwrap();
+        let raiz = tmp.path().to_path_buf();
+
+        let dentro = Arc::new(AtomicBool::new(false));
+        let piso = Arc::new(AtomicBool::new(false));
+
+        let g1 = tomar_guarda(&raiz, "misma").expect("primera guarda");
+        dentro.store(true, Ordering::SeqCst);
+
+        let (r2, d2, p2) = (raiz.clone(), dentro.clone(), piso.clone());
+        let h = std::thread::spawn(move || {
+            let _g2 = tomar_guarda(&r2, "misma").expect("segunda guarda");
+            // Si entró mientras la primera seguía viva, el lock no sirve.
+            if d2.load(Ordering::SeqCst) {
+                p2.store(true, Ordering::SeqCst);
+            }
+        });
+
+        std::thread::sleep(std::time::Duration::from_millis(250));
+        dentro.store(false, Ordering::SeqCst);
+        drop(g1);
+        h.join().unwrap();
+        assert!(!piso.load(Ordering::SeqCst), "la segunda guarda entró con la primera viva");
+
+        // CONTROL: dos nombres distintos NO se bloquean — si esto fallara, el lock serializaría
+        // builds que no comparten nada y la granja perdería el paralelismo.
+        let _a = tomar_guarda(&raiz, "una").expect("guarda de `una`");
+        let _b = tomar_guarda(&raiz, "otra").expect("guarda de `otra` (no debe bloquear)");
+    }
+
+    /// El fichero de lock NO puede vivir dentro de `sources/`: el propio fetch hace
+    /// `remove_dir_all` del árbol, y un lock ahí adentro se borraría a sí mismo.
+    #[test]
+    fn el_lock_vive_fuera_de_sources() {
+        let tmp = tempfile::tempdir().unwrap();
+        let _g = tomar_guarda(tmp.path(), "algo").unwrap();
+        assert!(tmp.path().join("locks/algo.lock").is_file());
+        assert!(!tmp.path().join("sources/algo.lock").exists());
+    }
+
     #[test]
     fn resolver_url_reescribe_ssh_a_https() {
         assert_eq!(
diff --git a/crates/takana-build/src/lib.rs b/crates/takana-build/src/lib.rs
index 87326911..e7f9805b 100644
--- a/crates/takana-build/src/lib.rs
+++ b/crates/takana-build/src/lib.rs
@@ -217,7 +217,22 @@ pub fn build(
     }
 
     tracing::info!(hash = %h, name = %recipe.name, "fetch");
-    let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
+    // ADR 0012 DECIDIDO (2026-09-22): el árbol de fuentes se toma con un lock EXCLUSIVO por receta
+    // que vive hasta el `seal` —`_guarda_arbol` es RAII—, no sólo durante la extracción. Un lock que
+    // cubriera nada más el `tar -x` no cierra la carrera: el árbol se muta después (patches,
+    // vendoreo) y se bindea para compilar. Ver la doc de `fetch::GuardaArbol` para por qué esto NO
+    // serializa la granja (dos recetas distintas no comparten su árbol) ni puede deadlockear (las
+    // deps se construyen ANTES, así que los locks no se anidan).
+    let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?;
+
+    // RE-CHEQUEO tras el lock (double-checked locking). Si dos procesos pedían la MISMA receta, el
+    // que esperó encuentra el artefacto ya sellado por el otro y sale por cache hit en vez de
+    // reconstruirlo — que además dejaría dos `seal` compitiendo por el mismo nombre en el store.
+    if store.has(&h, &recipe.name) {
+        tracing::info!(hash = %h, name = %recipe.name,
+                       "caché: otro build lo selló mientras esperábamos el árbol");
+        return Ok(h);
+    }
 
     if !recipe.source.patches.is_empty() {
         tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
@@ -478,7 +493,10 @@ pub fn run_evidence(
     let mut dep_dirs = materialize_build_deps(recipe, cfg, store)?;
     dep_dirs.push(artifact_dir);
 
-    let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
+    // Misma guarda que el build: este camino re-materializa el árbol y le aplica los patches, o sea
+    // que MUTA estado compartido. Sin el lock, verificar la evidencia de una receta podía romper el
+    // árbol de un build en curso de la misma receta.
+    let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?;
     if !recipe.source.patches.is_empty() {
         fetch::apply_patches(recipe, &src_tree)?;
     }
diff --git a/crates/takana-build/tests/fetch_tarball.rs b/crates/takana-build/tests/fetch_tarball.rs
index 8de65ed2..34bc751d 100644
--- a/crates/takana-build/tests/fetch_tarball.rs
+++ b/crates/takana-build/tests/fetch_tarball.rs
@@ -73,7 +73,7 @@ fn happy_path_strip_components_default_one() {
     let recipe = recipe_for(&url, &sha, None);
     let work_root = tempfile::tempdir().unwrap();
 
-    let tree = fetch(&recipe, work_root.path()).expect("fetch tarball");
+    let (tree, _g) = fetch(&recipe, work_root.path()).expect("fetch tarball");
     // strip_components=1 ⇒ no debe quedar `inner/`, los archivos suben un nivel.
     assert!(tree.join("a.txt").is_file(), "a.txt debe existir en la raíz del tree");
     assert!(tree.join("sub/b.txt").is_file(), "sub/b.txt debe existir");
@@ -93,7 +93,7 @@ fn strip_components_zero_preserves_top_level() {
 
     let recipe = recipe_for(&url, &sha, Some(0));
     let work_root = tempfile::tempdir().unwrap();
-    let tree = fetch(&recipe, work_root.path()).expect("fetch tarball strip=0");
+    let (tree, _g) = fetch(&recipe, work_root.path()).expect("fetch tarball strip=0");
     assert!(tree.join("inner/a.txt").is_file(), "con strip=0, inner/ debe existir");
 }
 
@@ -131,14 +131,15 @@ fn second_fetch_reuses_cached_tarball() {
     let recipe = recipe_for(&url, &sha, None);
     let work_root = tempfile::tempdir().unwrap();
 
-    let _ = fetch(&recipe, work_root.path()).expect("first fetch");
+    let (_t1, g1) = fetch(&recipe, work_root.path()).expect("first fetch");
+    drop(g1); // el lock es por receta: soltarlo antes del segundo fetch, que es del mismo proceso
 
     // Para forzar que la segunda corrida NO pueda descargar, "rompo" la fuente: muevo el
     // tarball original a otro lado. Si el fetch va a la caché, sigue funcionando; si intenta
     // re-descargar, falla.
     std::fs::remove_file(&tar_path).unwrap();
 
-    let tree2 = fetch(&recipe, work_root.path()).expect("second fetch (debe usar caché)");
+    let (tree2, _g2) = fetch(&recipe, work_root.path()).expect("second fetch (debe usar caché)");
     assert!(tree2.join("a.txt").is_file());
 }
 
diff --git a/docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md b/docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
index 8f6274d6..15613c50 100644
--- a/docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
+++ b/docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
@@ -1,6 +1,8 @@
 # ADR 0012 — El árbol de fuentes es caché y workspace a la vez: carrera abierta en `fetch`
 
-- **Estado:** PENDIENTE — dilema abierto, **sin decidir**. No implementar sin resolverlo (ver §Opciones).
+- **Estado:** **ACEPTADA e IMPLEMENTADA (2026-09-22)** — opción **A precisada**: lock exclusivo por
+  RECETA, sostenido desde el `fetch` hasta el `seal`, con re-chequeo de la tienda tras tomarlo.
+  Ver §La decisión al final. El dilema se resolvió midiendo, no opinando.
 - **Fecha:** 2026-07-22
 - **Frontera:** `crates/hammer-build/src/fetch.rs` (`fetch_tarball` / `fetch_git`),
   `crates/hammer-build/src/lib.rs:208`. Mitigación parcial ya en árbol: commit `1b436a7`.
@@ -88,3 +90,100 @@ Sin esos tres números, cualquiera de las tres se elige a ciegas.
 Hay **otro agente trabajando sobre este repo**. Este ADR existe para que la carrera quede
 registrada y nadie la "arregle" a medias: un lock sólo en la extracción parece correcto y **no lo
 es** (§El dilema). Si vas a tocarlo, resolvé el dilema primero.
+
+
+---
+
+# La decisión (2026-09-22): opción A, precisada — y por qué su coste temido no existe
+
+## Las tres mediciones que este ADR pedía
+
+**1. ¿Cuánto serializaría A en la práctica?** La pregunta estaba mal planteada, y verlo es la mitad
+de la decisión. A bloquea **árboles**, y el orden real de `build` (`lib.rs`) es:
+
+```
+1. store.has(h)              → si está, vuelve sin tocar NINGÚN árbol
+2. materialize_build_deps()  → construye las deps (recursivo) y TERMINA
+3. fetch → patches → vendor → compilar → sellar     ← recién acá se toca el árbol PROPIO
+```
+
+De ahí salen dos hechos que el §Opciones no había mirado:
+
+- **Un consumidor NO toca el árbol de su dep**: recibe el ARTEFACTO SELLADO como capa overlay. O sea
+  que el «diamante que converge» —`todo KF6/Qt converge en qtbase`— **no serializa nada**: dos
+  recetas distintas nunca comparten su árbol propio. Lo único que se serializa es construir la MISMA
+  receta dos veces a la vez, que es justo lo que no se debe hacer.
+- **Los locks no se anidan**: cuando un build toma el lock de su árbol, sus deps ya están construidas
+  y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin ordenar nada.
+
+**2. ¿Cuánto cuesta B (re-extraer + re-vendorear por build)?** Medido en el build real de
+`ntpd-rs` (Cargo, 150 crates) del 2026-09-22, con marcas de tiempo del propio log:
+
+| fase | tiempo |
+|---|---|
+| fetch (clone + archive) | **4,0 s** |
+| `cargo vendor` | **5,4 s** |
+| compilar | **6 min 25 s** |
+
+⇒ re-extraer y re-vendorear cuesta **2,4 %** del build. **El tiempo no era el problema de B: el disco
+sí.** Ese árbol pesa **707 MiB**, y B multiplica eso por cada build concurrente.
+
+**3. ¿Espacio pico de C?** La caché de fuentes de este hub son **12,4 GiB en 46 árboles**, con los
+mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). C —caché inmutable + copia
+privada— duplica lo que esté en vuelo: con 4 builds concurrentes de los gordos son ~10 G extra sobre
+un `DISK_HIGH=82`. Y C **no ahorra el vendoreo** (que muta, así que va en la copia): compra sólo los
+4 s del extract, al precio de duplicar el disco.
+
+## La decisión
+
+**Opción A precisada:** un lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por
+RAII hasta que el artefacto se sella (`fetch::GuardaArbol`), más un **re-chequeo de la tienda** tras
+tomarlo (double-checked locking): el que esperó encuentra el artefacto ya sellado y sale por cache
+hit en vez de reconstruirlo.
+
+- **El grano es el NOMBRE y no el árbol** porque el mirror git (`repos/<nombre>.git`) también es
+  estado compartido y su clave es sólo el nombre: dos builds de la misma receta a distinto commit
+  tienen árboles distintos y el MISMO mirror, y un `clone --mirror` sobre un directorio existente
+  falla.
+- **El lock cubre extract + patches + vendor + compile + seal**, que es lo que el §El dilema exigía:
+  un lock sólo alrededor de la extracción no cierra la carrera.
+- **El fd no se hereda.** Rust abre con `O_CLOEXEC`, así que ni `tar`, ni `git`, ni `bwrap`, ni un
+  nieto fugado se quedan con el lock — que es exactamente el modo de fallo que `CLAUDE.md` documenta
+  para el `flock(1)` de alrededor (hora y media de granja parada, en silencio).
+
+## Lo verificado
+
+**Dos builds concurrentes de la misma receta**, con el árbol borrado antes para forzar la extracción:
+
+```
+A: fetch … → sealed
+B: fetch … → «árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta»
+           → «árbol: lock obtenido tras esperar»  (1,27 s)
+           → «caché: otro build lo selló mientras esperábamos el árbol»
+los dos rc=0 · UN artefacto en el store
+```
+
+**Y el control, que es lo que prueba que el lock hace falta.** Modelando la ventana real (extracción
+de segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del
+apagón de KDE del 22/07:
+
+```
+SIN lock:  A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes      B: OK
+CON lock:  A: OK                                            B: OK
+```
+
+Más dos tests en el crate (`fetch::tests`): que dos guardas del mismo nombre no coexisten, con el
+CONTROL de que dos nombres distintos no se bloquean —si eso fallara, el lock serializaría builds que
+no comparten nada—, y que el fichero de lock vive **fuera** de `sources/` (adentro lo barrería el
+propio `remove_dir_all` que estamos cerrando).
+
+## Lo que esto habilita, y lo que NO decide
+
+Habilita subir el paralelismo: `JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos
+**por esta carrera**, y la carrera ya no existe. **Subirlo es otra decisión y no se toma acá**: el
+worker son 6 cores y 16 G con 8 G de swap, y esta misma clase de máquina ya provocó un `global_oom`.
+Lo que cambia es que ahora el límite es la RAM y el disco —medibles y con watchdog— y no la
+corrupción silenciosa de un árbol compartido.
+
+El `flock -o work/.farm-build.lock` de `CLAUDE.md` §1 **sigue haciendo falta por otra razón**: serializa
+contra `farm-worker-loop.sh` y `campana-deuda.sh`, que es control de RECURSOS, no de corrupción.
diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index 336810e5..4251de3f 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -754,3 +754,87 @@ non-fast-forward, o sea que habló TLS y autenticó.
 Arreglado donde se genera (`scripts/servidor/jaula-herramientas.sh`: `git` sale del bucle y lleva su
 `export GIT_EXEC_PATH="$b/usr/libexec/git-core"`) y en el envoltorio vivo `/work/sergio/bin/git`.
 ⇒ **El push doble desde la jaula deja de estar roto**, que era una molestia diaria documentada.
+
+---
+
+# Paso 6 — HECHO: el ADR 0012, decidido e implementado (2026-09-22)
+
+Estaba PENDIENTE desde el 2026-07-22 con una nota que decía «no implementar sin resolver el dilema».
+El dilema se resolvió **midiendo las tres cosas que el propio ADR pedía**, y la primera resultó estar
+mal planteada — verlo es la mitad de la decisión.
+
+## 1 · «¿Cuánto serializaría un lock por árbol?» — nada, y el ADR no lo había mirado
+
+El ADR descartaba a medias la opción A porque «el grafo es un diamante que converge ⇒ puede degenerar
+en serializar casi todo». Eso supone que un consumidor toca el árbol de su dep. **No lo toca:**
+
+```
+1. store.has(h)              → si está, vuelve sin tocar NINGÚN árbol
+2. materialize_build_deps()  → construye las deps (recursivo) y TERMINA
+3. fetch → patches → vendor → compilar → sellar   ← recién acá se toca el árbol PROPIO
+```
+
+- Un consumidor recibe el **artefacto sellado** de su dep como capa overlay, no su árbol ⇒ dos
+  recetas distintas **nunca comparten su árbol propio** y no se serializan entre sí.
+- Los locks **no se anidan**: al tomar el lock del árbol propio, las deps ya están construidas y sus
+  locks liberados ⇒ **no puede haber deadlock**, sin ordenar nada ni detectar ciclos.
+
+## 2 · «¿Cuánto cuesta re-extraer y re-vendorear?» — 2,4 %, pero el disco duele
+
+Medido en el build real de `ntpd-rs` (el del paso 5), con las marcas de tiempo de su propio log:
+fetch **4,0 s** + `cargo vendor` **5,4 s** contra **6 min 25 s** de compilación. El tiempo no era el
+problema de la opción B; el disco sí: ese árbol pesa **707 MiB**, y B lo multiplica por build
+concurrente.
+
+## 3 · «¿Espacio pico de la caché + copia?» — 12,4 GiB en 46 árboles
+
+Con los mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). La opción C duplica lo
+que esté en vuelo y **no ahorra el vendoreo** (que muta ⇒ va en la copia): compra los 4 s del extract
+al precio de duplicar el disco.
+
+## La decisión: opción A, precisada
+
+Lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por RAII hasta el `seal`
+(`fetch::GuardaArbol`), más **re-chequeo de la tienda** al obtenerlo: el que esperó sale por cache hit
+en vez de reconstruir. El grano es el nombre —y no el árbol— porque el mirror git
+(`repos/<nombre>.git`) también es estado compartido y su clave es sólo el nombre.
+
+⚠ **El fd no se hereda**, y eso es el punto fino: Rust abre con `O_CLOEXEC`, así que ni `tar`, ni
+`git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que
+`CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio).
+
+## Verificado, con control
+
+Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción:
+
+```
+A: fetch … → sealed
+B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s)
+   → «caché: otro build lo selló mientras esperábamos el árbol»
+los dos rc=0 · UN artefacto
+```
+
+Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de
+segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón
+de KDE del 22/07:
+
+```
+SIN lock:  A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes      B: OK
+CON lock:  A: OK                                            B: OK
+```
+
+Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de
+que el fichero de lock vive **fuera** de `sources/`, donde el propio `remove_dir_all` lo barrería.
+
+**Y no mueve ningún hash**: el artefacto de la prueba salió `b3:dd6b538af774…`, idéntico al que el
+grafo ya registraba. La batería del workspace pasa entera salvo un test de `swm` que ya estaba roto
+antes (de otro agente).
+
+## Lo que habilita, y lo que NO decide
+
+`JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**. Ya no hay
+carrera. **Subir el paralelismo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G, y
+esta clase de máquina ya provocó un `global_oom`. Lo que cambia es que el límite pasa a ser RAM y
+disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el
+`flock` de `CLAUDE.md` §1 sigue haciendo falta por otra razón: serializa contra los scripts de la
+granja, que es control de RECURSOS.