> 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…
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.
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.