tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

feat(shipote): quota enforce + cgroup memory.max + pipeline restart (fase L)

- WorkspaceSpec.quota_enforce: QuotaAction (None|Log|Kill) por recurso
  (mem, nproc). reap_dead aplica policy; Kill usa stop_with_grace(ZERO).
- ente_incarnate::cgroup::apply_rlimits_to_cgroup escribe memory.max y
  pids.max. WorkspaceManager::create_with_id lo invoca si soma.cgroup.path
  y delegation. Kernel hace OOM kill al exceder; falla silenciosa si no
  hay delegation.
- PipelineSpec.restart_on_failure: bool. register_pipeline_supervisor
  retiene spec; reap_dead detecta all-dead + any-failed → push a queue;
  daemon reaper drena y relanza pipeline ENTERO (los pipes intermedios
  no permiten restart parcial).

82 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 24, shipote-discern 5, yahweh-provider-fs 3).

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

4c9d1b4c1dbc62cf7d9cfde211524eb06ba21144 · by sergio · (5 months ago) · parent 324a0c2d5 · browse the tree at this commit

Semantic delta · 35

crates/apps/shipote-daemon/src/main.rs

crates/modules/shipote/shipote-card/src/lib.rs

crates/modules/shipote/shipote-core/src/lib.rs

crates/modules/shipote/shipote-core/src/persist.rs

crates/modules/shipote/shipote-core/src/pipeline.rs

crates/modules/shipote/shipote-protocol/src/lib.rs

crates/shared/ente-incarnate/src/cgroup.rs

7 files · +401 −5

diff --git a/crates/apps/shipote-daemon/src/main.rs b/crates/apps/shipote-daemon/src/main.rs
index 9f024b0..939fec3 100644
--- a/crates/apps/shipote-daemon/src/main.rs
+++ b/crates/apps/shipote-daemon/src/main.rs
@@ -93,7 +93,8 @@ async fn main() -> anyhow::Result<()> {
 
     let discerner = Arc::new(DiscernPipeline::default_pipeline());
 
-    // Reaper periódico cada 500 ms.
+    // Reaper periódico cada 500 ms. Además drena pipelines pendientes
+    // de restart (supervisión a nivel pipeline).
     {
         let mgr = mgr.clone();
         tokio::spawn(async move {
@@ -101,6 +102,55 @@ async fn main() -> anyhow::Result<()> {
             loop {
                 tick.tick().await;
                 mgr.reap_dead().await;
+                let pending = mgr.take_pending_restarts().await;
+                for sup in pending {
+                    info!(
+                        label = %sup.spec.label,
+                        restart_count = sup.restart_count,
+                        "pipeline restart: relaunching"
+                    );
+                    let inc = mgr.incarnator_handle();
+                    let disc = std::sync::Arc::new(DiscernPipeline::default_pipeline());
+                    let ws_label = mgr
+                        .workspace_label(sup.spec.workspace)
+                        .await
+                        .unwrap_or_default();
+                    let restart_count = sup.restart_count;
+                    let workspace = sup.spec.workspace;
+                    let tap = sup.tap;
+                    let mut new_spec = sup.spec.clone();
+                    // Mantener restart_on_failure para futuras fallas.
+                    new_spec.restart_on_failure = true;
+                    match shipote_core::pipeline::run_pipeline(
+                        &new_spec,
+                        &ws_label,
+                        tap,
+                        disc,
+                        inc,
+                        Some(mgr.clone()),
+                    )
+                    .await
+                    {
+                        Ok(launch) => {
+                            mgr.register_pipeline_commands(workspace, launch.pipeline, launch.command_pids.clone())
+                                .await;
+                            // Re-registrar supervisor con el nuevo pipeline_id,
+                            // preservando restart_count.
+                            let mut s = shipote_core::PipelineSupervisor {
+                                workspace,
+                                spec: new_spec,
+                                tap,
+                                restart_count,
+                            };
+                            s.restart_count = restart_count;
+                            mgr.register_pipeline_supervisor(launch.pipeline, workspace, s.spec.clone(), tap)
+                                .await;
+                        }
+                        Err(e) => {
+                            warn!(?e, "pipeline restart failed");
+                        }
+                    }
+                }
             }
         });
     }
@@ -219,6 +269,7 @@ async fn dispatch(
                     announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
                     let cmds = launch.command_pids;
                     mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
+                    mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await;
                     let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
                     Response::PipelineStarted {
                         pipeline: pipeline_id,
@@ -318,6 +369,7 @@ async fn dispatch(
                         announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
                         let cmds = launch.command_pids;
                         mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
+                        mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await;
                         let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
                         Response::PipelineStarted {
                             pipeline: pipeline_id,
diff --git a/crates/modules/shipote/shipote-card/src/lib.rs b/crates/modules/shipote/shipote-card/src/lib.rs
index b1fc374..19873e1 100644
--- a/crates/modules/shipote/shipote-card/src/lib.rs
+++ b/crates/modules/shipote/shipote-card/src/lib.rs
@@ -94,6 +94,34 @@ pub struct WorkspaceSpec {
     /// Política al terminar el workspace.
     #[serde(default)]
     pub on_exit: ExitPolicy,
+
+    /// Política de enforcement automático cuando un recurso excede su
+    /// rlimit declarado en `soma.rlimits`. Default = sólo accounting
+    /// (None) — el quota report sigue funcionando, pero no hay kill.
+    #[serde(default)]
+    pub quota_enforce: QuotaEnforcement,
+}
+
+/// Acción cuando un recurso excede su límite. Aplica por recurso (mem,
+/// nproc, ...).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum QuotaAction {
+    /// Sólo accounting: la breach aparece en `workspace_quota`.
+    #[default]
+    None,
+    /// Loguear la breach (info-level del daemon).
+    Log,
+    /// Matar todos los comandos vivos del workspace (SIGKILL, sin grace).
+    Kill,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct QuotaEnforcement {
+    #[serde(default)]
+    pub mem: QuotaAction,
+    #[serde(default)]
+    pub nproc: QuotaAction,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -189,6 +217,11 @@ pub struct PipelineSpec {
     pub edges: Vec<FlowEdge>,
     #[serde(default)]
     pub discern: DiscernPolicy,
+    /// Si `true` y cualquier comando del pipeline termina con exit!=0,
+    /// el daemon relaunch el pipeline ENTERO (stop + nuevo run_pipeline).
+    /// Útil para pipelines de procesamiento continuo.
+    #[serde(default)]
+    pub restart_on_failure: bool,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -468,6 +501,7 @@ mod subst_tests {
             }],
             edges: vec![],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         let out = substitute_vars(&spec, &vars).unwrap();
         assert_eq!(out.label, "p-renamed");
@@ -487,6 +521,7 @@ mod subst_tests {
             nodes: vec![],
             edges: vec![],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         let out = substitute_vars(&spec, &vars).unwrap();
         assert_eq!(out.label, "p-${UNDEFINED}");
@@ -509,6 +544,7 @@ mod tests {
                 scope: FlowScope::Public,
             }],
             on_exit: ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         }
     }
 
@@ -566,6 +602,7 @@ mod tests {
                 to_input: "y".into(),
             }],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         assert!(p.validate().is_err());
     }
diff --git a/crates/modules/shipote/shipote-core/src/lib.rs b/crates/modules/shipote/shipote-core/src/lib.rs
index 239b60f..9673dc3 100644
--- a/crates/modules/shipote/shipote-core/src/lib.rs
+++ b/crates/modules/shipote/shipote-core/src/lib.rs
@@ -99,6 +99,21 @@ struct Inner {
     /// con la misma spec (nuevo pid y nuevo command_id se asigna por
     /// el nuevo state pero el restart_spec sigue ligado al original).
     restart_specs: HashMap<Ulid, RestartSpec>,
+    /// Supervisores de pipelines con `restart_on_failure`. Indexed por
+    /// pipeline_id. Cuando `reap_dead` detecta que el pipeline tuvo
+    /// algún command failure, agrega un entry a `pending_pipeline_restarts`.
+    pipeline_supervisors: HashMap<Ulid, PipelineSupervisor>,
+    /// Cola de pipelines pendientes de restart. El daemon la drena en
+    /// cada loop del reaper, hace stop + run_pipeline.
+    pending_pipeline_restarts: Vec<Ulid>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PipelineSupervisor {
+    pub workspace: WorkspaceId,
+    pub spec: PipelineSpec,
+    pub tap: bool,
+    pub restart_count: u32,
 }
 
 #[derive(Debug, Clone)]
@@ -131,6 +146,19 @@ pub struct CommandInfo {
     pub log_bytes: u64,
 }
 
+/// Lee VmRSS (bytes) de `/proc/<pid>/status`. Helper local para
+/// reap_dead que no necesita el full stats. Devuelve 0 si el proc no
+/// existe o el campo no aparece.
+fn read_proc_rss(pid: i32) -> Option<u64> {
+    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
+    status
+        .lines()
+        .find_map(|l| l.strip_prefix("VmRSS:").map(str::trim))
+        .and_then(|s| s.split_whitespace().next())
+        .and_then(|s| s.parse::<u64>().ok())
+        .map(|kb| kb * 1024)
+}
+
 fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
     // Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
     // SAFETY: F_SETFL sobre fd válido.
@@ -198,11 +226,54 @@ impl WorkspaceManager {
                 saved_pipelines: HashMap::new(),
                 pipeline_flows: HashMap::new(),
                 restart_specs: HashMap::new(),
+                pipeline_supervisors: HashMap::new(),
+                pending_pipeline_restarts: Vec::new(),
             })),
             incarnator: Arc::new(Incarnator::new(cfg)),
         }
     }
 
+    /// Registra un supervisor para un pipeline con `restart_on_failure=true`.
+    /// El daemon llama esto tras `run_pipeline` para que `reap_dead` agregue
+    /// el pipeline a la cola de restart cuando algún command falle.
+    pub async fn register_pipeline_supervisor(
+        &self,
+        pipeline_id: Ulid,
+        workspace: WorkspaceId,
+        spec: PipelineSpec,
+        tap: bool,
+    ) {
+        if !spec.restart_on_failure {
+            return;
+        }
+        tracing::debug!(%pipeline_id, label = %spec.label, "pipeline supervisor registered");
+        let mut g = self.inner.lock().await;
+        g.pipeline_supervisors.insert(
+            pipeline_id,
+            PipelineSupervisor {
+                workspace,
+                spec,
+                tap,
+                restart_count: 0,
+            },
+        );
+    }
+
+    /// Drena la cola de pipelines pendientes de restart y retorna las
+    /// specs a relaunch. El daemon lo llama tras cada `reap_dead`.
+    pub async fn take_pending_restarts(&self) -> Vec<PipelineSupervisor> {
+        let mut g = self.inner.lock().await;
+        let pending = std::mem::take(&mut g.pending_pipeline_restarts);
+        let mut out = Vec::with_capacity(pending.len());
+        for old_id in pending {
+            if let Some(mut sup) = g.pipeline_supervisors.remove(&old_id) {
+                sup.restart_count += 1;
+                out.push(sup);
+            }
+        }
+        out
+    }
+
     /// Registra los comandos lanzados por un pipeline en el workspace.
     /// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
     /// de un pipeline). `pipeline_id` se setea en cada CommandState.
@@ -465,8 +536,22 @@ impl WorkspaceManager {
         spec: WorkspaceSpec,
     ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
         let card = spec.to_card(id)?;
-        let warnings = self.incarnator.dry_run(&card).warnings;
+        let mut warnings = self.incarnator.dry_run(&card).warnings;
         let ttl = spec.ttl;
+
+        // Si el workspace declara cgroup path Y rlimits, intentamos
+        // crear el cgroup y escribir memory.max/pids.max. El kernel
+        // hace OOM kill al exceder memory.max — enforcement automático
+        // sin policy adicional. Falla silenciosa si no hay delegation.
+        if !spec.soma.cgroup.path.is_empty() {
+            if let Ok(abs) = ente_incarnate::cgroup::ensure_cgroup(&spec.soma.cgroup) {
+                let applied =
+                    ente_incarnate::cgroup::apply_rlimits_to_cgroup(&abs, &spec.soma.rlimits);
+                if !applied.is_empty() {
+                    warnings.push(format!("cgroup limits applied: {}", applied.join(", ")));
+                }
+            }
+        }
         let state = WorkspaceState {
             id,
             spec,
@@ -778,6 +863,7 @@ impl WorkspaceManager {
     /// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
     pub async fn reap_dead(self: &Arc<Self>) {
         let mut to_restart: Vec<RestartSpec> = Vec::new();
+        let mut to_enforce_kill: Vec<WorkspaceId> = Vec::new();
         {
             let mut g = self.inner.lock().await;
             for ws in g.workspaces.values_mut() {
@@ -798,6 +884,93 @@ impl WorkspaceManager {
                     }
                 }
             }
+            // Quota enforcement: chequear breach por workspace y aplicar policy.
+            // Lo hacemos dentro del mismo lock para tener una lectura
+            // consistente; el kill real va fuera del lock.
+            for (ws_id, ws) in g.workspaces.iter() {
+                let rl = &ws.spec.soma.rlimits;
+                let qe = &ws.spec.quota_enforce;
+                // Sólo aplicamos si hay al menos una action != None.
+                if qe.mem == shipote_card::QuotaAction::None
+                    && qe.nproc == shipote_card::QuotaAction::None
+                {
+                    continue;
+                }
+                // Medir RSS y nproc vivos sin pasar por workspace_stats
+                // (que tomaría el lock recursivo). Hacemos un read directo.
+                let alive: Vec<i32> = ws
+                    .commands
+                    .values()
+                    .filter(|c| c.alive)
+                    .map(|c| c.pid.as_raw())
+                    .collect();
+                let nproc_alive = alive.len() as u32;
+                let mem_used: u64 = alive
+                    .iter()
+                    .filter_map(|pid| read_proc_rss(*pid))
+                    .sum();
+
+                let mem_breach = matches!(rl.mem_bytes, Some(limit) if mem_used > limit);
+                let nproc_breach = matches!(rl.nproc, Some(limit) if nproc_alive > limit);
+
+                let mut kill_needed = false;
+                if mem_breach {
+                    match qe.mem {
+                        shipote_card::QuotaAction::Log => {
+                            warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: memory");
+                        }
+                        shipote_card::QuotaAction::Kill => {
+                            warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: KILLING");
+                            kill_needed = true;
+                        }
+                        _ => {}
+                    }
+                }
+                if nproc_breach {
+                    match qe.nproc {
+                        shipote_card::QuotaAction::Log => {
+                            warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: nproc");
+                        }
+                        shipote_card::QuotaAction::Kill => {
+                            warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: KILLING");
+                            kill_needed = true;
+                        }
+                        _ => {}
+                    }
+                }
+                if kill_needed {
+                    to_enforce_kill.push(*ws_id);
+                }
+            }
+            // Pipeline supervisor: detectar pipelines cuyos comandos tienen
+            // failure. Marca para restart si tiene supervisor.
+            // Esto se hace cuando TODOS los comandos del pipeline están
+            // dead Y al menos uno tiene exit!=0 (sino podría disparar
+            // restart mientras otros comandos aún corren — incorrecto).
+            let supervisor_ids: Vec<Ulid> = g.pipeline_supervisors.keys().copied().collect();
+            for pipe_id in supervisor_ids {
+                // ¿Hay algún comando vivo de este pipeline?
+                let mut all_dead = true;
+                let mut any_failed = false;
+                for ws in g.workspaces.values() {
+                    for cmd in ws.commands.values() {
+                        if cmd.pipeline_id != Some(pipe_id) {
+                            continue;
+                        }
+                        if cmd.alive {
+                            all_dead = false;
+                        } else if cmd.exit_status.map_or(false, |s| s != 0) {
+                            any_failed = true;
+                        }
+                    }
+                }
+                if all_dead && any_failed {
+                    // Push a queue si no estaba ya.
+                    if !g.pending_pipeline_restarts.contains(&pipe_id) {
+                        g.pending_pipeline_restarts.push(pipe_id);
+                    }
+                }
+            }
             // Detectar restart_specs cuyo command_id ya está dead con exit!=0.
             let mut to_remove: Vec<Ulid> = Vec::new();
             for (cmd_id, spec) in g.restart_specs.iter() {
@@ -826,6 +999,10 @@ impl WorkspaceManager {
                 g.restart_specs.remove(&id);
             }
         }
+        // Quota enforcement: kill workspaces fuera del lock.
+        for ws_id in to_enforce_kill {
+            let _ = self.stop_with_grace(ws_id, std::time::Duration::ZERO).await;
+        }
         // Schedule restart fuera del lock.
         for mut spec in to_restart {
             let mgr = self.clone();
@@ -900,6 +1077,7 @@ mod tests {
             ttl: Some(std::time::Duration::from_millis(120)),
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _) = mgr.create(spec).await.unwrap();
         assert_eq!(mgr.list().await.len(), 1);
@@ -922,6 +1100,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _w) = mgr.create(spec).await.unwrap();
         let list = mgr.list().await;
@@ -939,6 +1118,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _) = mgr.create(spec).await.unwrap();
         let summary = mgr
@@ -971,6 +1151,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _) = mgr.create(spec).await.unwrap();
         // sh -c "echo OUT; echo ERR >&2"
@@ -1017,6 +1198,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _) = mgr.create(spec).await.unwrap();
         // /bin/false sale con exit=1. Con restart_on_failure=true debería
@@ -1042,6 +1224,98 @@ mod tests {
         panic!("restart never launched a new command");
     }
 
+    #[tokio::test]
+    async fn pipeline_supervisor_queues_restart_on_failure() {
+        use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec};
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let (ws_id, _) = mgr.create(WorkspaceSpec {
+            label: "psup".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
+        }).await.unwrap();
+        let spec = PipelineSpec {
+            label: "fail-pipeline".into(),
+            workspace: ws_id,
+            nodes: vec![CommandRef {
+                label: "boom".into(),
+                payload: brahman_card::Payload::Native {
+                    exec: "/bin/false".into(),
+                    argv: vec![],
+                    envp: vec![],
+                },
+                soma: Default::default(),
+                flows: Default::default(),
+                supervision: brahman_card::Supervision::OneShot,
+            }],
+            edges: vec![],
+            discern: DiscernPolicy::default(),
+            restart_on_failure: true,
+        };
+        let pipeline_id = ulid::Ulid::new();
+        // Simulamos lo que haría el daemon: registramos un comando como
+        // si fuera de pipeline. Usamos `register_pipeline_commands` con
+        // un pid fake — pero como reaper hace waitpid, mejor lanzar de verdad.
+        // Hack: usar /bin/false via run() y manualmente marcar pipeline_id.
+        let summary = mgr.run(ws_id, "/bin/false".into(), vec![], vec![]).await.unwrap();
+        // Marcar el comando con pipeline_id manualmente.
+        {
+            let mut g = mgr.inner.lock().await;
+            if let Some(ws) = g.workspaces.get_mut(&ws_id) {
+                if let Some(cmd) = ws.commands.get_mut(&summary.id) {
+                    cmd.pipeline_id = Some(pipeline_id);
+                }
+            }
+        }
+        mgr.register_pipeline_supervisor(pipeline_id, ws_id, spec, true).await;
+        // Esperamos que reap detecte la falla y push a pending.
+        for _ in 0..40 {
+            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+            mgr.reap_dead().await;
+            let pending = mgr.take_pending_restarts().await;
+            if !pending.is_empty() {
+                assert_eq!(pending[0].spec.label, "fail-pipeline");
+                return;
+            }
+        }
+        panic!("supervisor never queued a restart");
+    }
+
+    #[tokio::test]
+    async fn quota_enforce_nproc_kill_terminates_commands() {
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let mut spec = WorkspaceSpec {
+            label: "qenforce".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: shipote_card::QuotaEnforcement {
+                mem: shipote_card::QuotaAction::None,
+                nproc: shipote_card::QuotaAction::Kill,
+            },
+        };
+        spec.soma.rlimits.nproc = Some(1);
+        let (id, _) = mgr.create(spec).await.unwrap();
+        // Lanzo 2 procesos (cada uno sleep). nproc_limit=1 → breach inmediato.
+        let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+        let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+        // Reaper detecta breach y mata workspace.
+        for _ in 0..30 {
+            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+            mgr.reap_dead().await;
+            let alive = mgr.list().await;
+            if alive.is_empty() {
+                return; // workspace removido por stop()
+            }
+        }
+        panic!("quota enforce kill never triggered");
+    }
+
     #[tokio::test]
     async fn run_true_in_workspace() {
         let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
@@ -1052,6 +1326,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: shipote_card::ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         };
         let (id, _) = mgr.create(spec).await.unwrap();
         let summary = mgr
diff --git a/crates/modules/shipote/shipote-core/src/persist.rs b/crates/modules/shipote/shipote-core/src/persist.rs
index a4e1e44..ca8f862 100644
--- a/crates/modules/shipote/shipote-core/src/persist.rs
+++ b/crates/modules/shipote/shipote-core/src/persist.rs
@@ -162,6 +162,7 @@ mod tests {
             ttl: None,
             flow_dirs: vec![],
             on_exit: ExitPolicy::Reap,
+            quota_enforce: Default::default(),
         }
     }
 
@@ -208,6 +209,7 @@ mod tests {
             }],
             edges: vec![],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         mgr1.save_pipeline("daily".into(), spec).await;
         mgr1.save_snapshot(&path).await.unwrap();
diff --git a/crates/modules/shipote/shipote-core/src/pipeline.rs b/crates/modules/shipote/shipote-core/src/pipeline.rs
index ece10e6..150c158 100644
--- a/crates/modules/shipote/shipote-core/src/pipeline.rs
+++ b/crates/modules/shipote/shipote-core/src/pipeline.rs
@@ -594,6 +594,7 @@ mod tests {
                 to_input: "stdin".into(),
             }],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         let disc = Arc::new(DiscernPipeline::default_pipeline());
         let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
@@ -631,6 +632,7 @@ mod tests {
                 },
             ],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         let disc = Arc::new(DiscernPipeline::default_pipeline());
         let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
@@ -667,6 +669,7 @@ mod tests {
                 },
             ],
             discern: DiscernPolicy::default(),
+            restart_on_failure: false,
         };
         let disc = Arc::new(DiscernPipeline::default_pipeline());
         let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
@@ -698,6 +701,7 @@ mod tests {
                 replay_chunks: 32,
                 replay_bytes: 0,
             },
+            restart_on_failure: false,
         };
         let disc = Arc::new(DiscernPipeline::default_pipeline());
         let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
diff --git a/crates/modules/shipote/shipote-protocol/src/lib.rs b/crates/modules/shipote/shipote-protocol/src/lib.rs
index 51d130c..598c17e 100644
--- a/crates/modules/shipote/shipote-protocol/src/lib.rs
+++ b/crates/modules/shipote/shipote-protocol/src/lib.rs
@@ -374,6 +374,7 @@ mod tests {
                 ttl: None,
                 flow_dirs: vec![],
                 on_exit: shipote_card::ExitPolicy::Reap,
+                quota_enforce: Default::default(),
             },
         };
         let bytes = postcard::to_allocvec(&req).unwrap();
diff --git a/crates/shared/ente-incarnate/src/cgroup.rs b/crates/shared/ente-incarnate/src/cgroup.rs
index 580d478..59e2680 100644
--- a/crates/shared/ente-incarnate/src/cgroup.rs
+++ b/crates/shared/ente-incarnate/src/cgroup.rs
@@ -1,8 +1,8 @@
 //! Resolución y creación de cgroups v2 para el hijo.
 
 use crate::error::IncarnateError;
-use brahman_card::CgroupSpec;
-use std::path::PathBuf;
+use brahman_card::{CgroupSpec, ResourceLimits};
+use std::path::{Path, PathBuf};
 
 /// Cgroup actual del proceso que llama. Lo usamos como prefijo para paths
 /// declarados relativos en `CgroupSpec.path`.
@@ -58,8 +58,33 @@ pub fn ensure_cgroup(spec: &CgroupSpec) -> Result<PathBuf, IncarnateError> {
     Ok(abs)
 }
 
+/// Escribe `memory.max` y `pids.max` al cgroup según `rlimits`. Falla
+/// silenciosamente si los archivos no son escribibles (cgroup no
+/// delegated). El kernel hace OOM kill cuando `memory.max` se excede,
+/// y bloquea forks cuando `pids.max` se alcanza.
+///
+/// `memory.max` acepta `max` o un número en bytes. `pids.max` igual.
+pub fn apply_rlimits_to_cgroup(cgroup_abs: &Path, rlimits: &ResourceLimits) -> Vec<String> {
+    let mut applied = Vec::new();
+    if let Some(mem) = rlimits.mem_bytes {
+        let path = cgroup_abs.join("memory.max");
+        match std::fs::write(&path, format!("{mem}\n")) {
+            Ok(_) => applied.push(format!("memory.max={mem}")),
+            Err(e) => tracing::warn!(?e, path = %path.display(), "memory.max write failed"),
+        }
+    }
+    if let Some(np) = rlimits.nproc {
+        let path = cgroup_abs.join("pids.max");
+        match std::fs::write(&path, format!("{np}\n")) {
+            Ok(_) => applied.push(format!("pids.max={np}")),
+            Err(e) => tracing::warn!(?e, path = %path.display(), "pids.max write failed"),
+        }
+    }
+    applied
+}
+
 /// Mueve `pid` a `cgroup_abs/cgroup.procs`.
-pub fn move_to_cgroup(cgroup_abs: &std::path::Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> {
+pub fn move_to_cgroup(cgroup_abs: &Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> {
     let procs = cgroup_abs.join("cgroup.procs");
     std::fs::write(&procs, format!("{}\n", pid.as_raw())).map_err(|e| match e.kind() {
         std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable {