Browse Source

engine: DGPP driver + native deploy (Phase 3) — head-coordinated, generated cluster JSON/.env

DeployTarget::Native: rank 0 runs the engine's cluster manager (DGPP's
dgpp-cluster up/down); the manager brings up peers. DgppDriver (no CLI model/
dist flags, fixed API port). Recipe [dgpp] block (http_port, dir, release,
free-form [dgpp.engine] table); native/mod.rs generates DGPP's cluster JSON
(model/world_size/http + engine verbatim) + sibling .env (DGPP_NODES/SSH_USER/
ports), uploads to rank 0 via heredoc (~→$HOME), runs dgpp-cluster up (nohup);
stop runs dgpp-cluster down. Readiness /v1/models on http_port. Unit tests for
JSON/env gen + expand_home. Spec + AGENTS updated. make check green (375 tests).
Alexander Korolev 1 week ago
parent
commit
fe1d70ada3

+ 14 - 0
AGENTS.md

@@ -341,6 +341,20 @@ The `deploy` field selects how the recipe is launched on each node:
   the launcher falls back to the engine's Python module path. Docker-only features are
   rejected: `[mods]`, `[composed_model]`, image sync, and `--sync-image`/`--build-node`/
   `--force-build` CLI opts are ignored with a warning.
+- `native`: a bare SSH command on **rank 0 only** driving an engine's own cluster
+  manager, which brings up the peers itself (head-coordinated). Used by DGPP
+  (`engine = "dgpp"`): the launcher generates DGPP's cluster JSON + a sibling
+  `.env` from the recipe's `[dgpp]` block + the selected cluster, uploads both to
+  `[dgpp].dir` on rank 0, and runs `scripts/dgpp-cluster up --config <file>`
+  (nohup, log under `<dir>/log/`); `stop` runs `scripts/dgpp-cluster down`.
+  Readiness is `/v1/models` on `[dgpp].http_port` (default 18080).
+
+**First-class engines** (`engine` selects an `EngineDriver` in `src/engine.rs`):
+`vllm` (default), `sglang`, `gr8` (`kindlingai/gr8`, docker deploy, `--tp/--peers`
+rendezvous, fixed API :8100), and `dgpp` (`HawkBearPig/dgpp`, native deploy).
+Engines whose driver returns `takes_host_port() == false` (gr8, dgpp) get no
+`--host/--port` and are exempt from `validate`'s port-convention rule (their API
+port is fixed). Design: `specs/tech-architecture/engine-drivers.md`.
 
 The `serve` command dispatches based on `recipe.deploy`:
 

+ 28 - 26
specs/tech-architecture/engine-drivers.md

@@ -37,24 +37,23 @@ Three orthogonal axes, each a first-class concept:
 ### `EngineDriver` trait (in `src/engine.rs`)
 
 ```rust
-pub enum LaunchShape { PerNode, HeadCoordinated }
-
+// Head-coordination is the deploy target (`deploy = "native"` = rank 0 runs
+// the engine's cluster manager), not a separate trait axis.
 pub trait EngineDriver: Send + Sync {
-    fn name(&self) -> &'static str;
-    fn launch_shape(&self) -> LaunchShape { LaunchShape::PerNode }
-    fn default_deploy(&self) -> DeployTarget { DeployTarget::Docker }
-
     // Model argument (positional for vLLM, --model-path for SGLang,
-    // --model for gr8, none for DGPP — the config file carries it).
+    // --model for gr8, none for DGPP — the cluster JSON carries it).
     fn model_args(&self, recipe: &Recipe) -> Vec<String>;
-    // Distributed rendezvous flags (vLLM/SGLang/gr8 sets; DGPP: none).
+    // Distributed rendezvous flags (vLLM/SGLang/gr8 sets; DGPP: none — the
+    // JSON carries world_size + rendezvous).
     fn distributed_args(&self, ctx: &EngineFlagsCtx, node: &Node) -> Vec<String>;
     // Non-head workers run headless (vLLM yes; SGLang/gr8/DGPP no).
     fn headless_workers(&self) -> bool { false }
     // Env var carrying the node's bind IP (VLLM_HOST_IP / SGLANG_WORKER_IP / none).
     fn engine_ip_env(&self) -> Option<&'static str> { None }
-    // Port flag on every node (SGLang) vs head only (vLLM/gr8/DGPP).
-    fn port_on_all_nodes(&self) -> bool { false }
+    // Takes --host/--port (vLLM head, SGLang all). Engines with a fixed API
+    // port (gr8 :8100, DGPP :18080) return false — no --host/--port, and
+    // `validate` exempts them from the port-convention rule.
+    fn takes_host_port(&self) -> bool { true }
     // tmux entrypoint fallback when the recipe sets none.
     fn entrypoint_default(&self) -> Vec<String>;
     // doctor install hint.
@@ -76,33 +75,36 @@ ignore it).
 
 ### gr8 driver (`kindlingai/gr8`)
 
-- `deploy = docker` (image `ghcr.io/kindlingai/gr8`), `launch_shape = PerNode`.
+- `deploy = docker` (image `ghcr.io/kindlingai/gr8`), per-node launch.
 - entrypoint `["/opt/gr8/gr8", "glm53", "up"]`; `model_args` → `--model <repo>`;
   `distributed_args` → `--tp <N> --peers <list>`; no `--headless`, no engine-IP
-  env, port on head only (API :8100 on the first peer).
+  env, `takes_host_port = false` (API :8100 on the first peer).
 - Recipe carries `--model-cache`, `--drafter`, `--shape` in `args`; the
   `vm.compaction_proactiveness=0` prerequisite is a `before_serve` host hook.
-- Stop: label-based `docker stop` works (the container is the engine); the
-  engine's own `gr8 glm53 down` is an optional `after_stop`.
+- Stop: label-based `docker stop` works (the container is the engine).
 
 ### DGPP driver (`HawkBearPig/dgpp`)
 
-- `deploy = native` (new), `launch_shape = HeadCoordinated`.
-- The recipe names a DGPP deployment template (`cluster_<model>_<quant>_w<n>.json`)
-  shipped next to the recipe; the launcher fills nodes/ssh-user/ports from the
-  cluster + a per-recipe `.env`, uploads it to rank 0, and runs
-  `scripts/dgpp-cluster up --config <path>` in a tmux session on rank 0 only.
-- Readiness: `/v1/models` on :18080 (uniform). Stop:
-  `scripts/dgpp-cluster down --config <path>` (an `after_stop` / native stop).
-- No CLI model/dist flags — the config JSON carries model + world size + knobs.
+- `deploy = native` (new), head-coordinated: rank 0 runs the manager.
+- The recipe's `[dgpp]` block carries `http_port`, `dir` (the DGPP checkout on
+  the nodes), optional `release`, and a free-form `[dgpp.engine]` table. The
+  launcher generates DGPP's cluster JSON (`model`, `world_size`, `http`, and
+  the `engine` table serialized verbatim) plus a sibling `.env`
+  (`DGPP_NODES`/`DGPP_SSH_USER`/ports), uploads both to `<dir>` on rank 0, and
+  runs `scripts/dgpp-cluster up --config <file>` there (nohup, log under
+  `<dir>/log/`). Stop runs `scripts/dgpp-cluster down` on rank 0.
+- Readiness is the uniform `/v1/models` probe on `http_port` (default 18080).
 
 ## Phasing
 
 1. **Trait + vLLM/SGLang drivers** — pure refactor, six call-sites delegate,
-   all tests green. (foundation)
-2. **gr8 driver** + `peers` ctx + a `glm53-full-tp4-gr8` recipe; dry-run + serve.
-3. **`Native` deploy + HeadCoordinated** + DGPP driver + config-JSON handling +
-   a `dgpp` recipe; serve.
+   all tests green. ✅ (launcher `engine:` Phase 1 commit)
+2. **gr8 driver** + `peers` ctx + `takes_host_port` + a `glm53-full-tp4-gr8`
+   recipe; validate + dry-run render the exact `gr8 glm53 up` contract. ✅
+3. **`Native` deploy** (head-coordinated) + DGPP driver + `[dgpp]` config-JSON
+   generation + a `qwen38-27b-nvfp4-dgpp` recipe; dry-run renders the JSON/`.env`
+   upload + `dgpp-cluster up/down`. ✅ (native serve/stop live-tested on first
+   real DGPP deploy)
 4. New-model recipes (Ling-3.1-flash, Step-5-Preview) — separate, gated on the
    bleeding-edge-base decision (nightly/dedicated image, 5–8 Sparks).
 

+ 1 - 0
src/after_stop.rs

@@ -224,6 +224,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 1 - 0
src/before_serve.rs

@@ -392,6 +392,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 33 - 0
src/config/recipe.rs

@@ -208,6 +208,39 @@ pub struct Recipe {
     /// See [`Staging`].
     #[serde(default)]
     pub staging: Vec<Staging>,
+    /// DGPP engine configuration (`engine = "dgpp"`, `deploy = "native"`).
+    /// The launcher generates the DGPP cluster JSON from these fields plus
+    /// the cluster's node list, uploads it to rank 0, and runs
+    /// `scripts/dgpp-cluster up --config <path>` there. See [`DgppConfig`].
+    #[serde(default)]
+    pub dgpp: Option<DgppConfig>,
+}
+
+/// DGPP (`HawkBearPig/dgpp`) deployment knobs. The launcher assembles the
+/// engine's cluster JSON as:
+/// `{ "model": <recipe.model>, "world_size": <launch nodes>,
+///    "http": { "bind_host": "0.0.0.0", "port": <http_port|18080> },
+///    "engine": <engine map, serialized verbatim>, "paths": {} }`
+/// and writes a sibling `.env` with the node list + ssh user (DGPP's
+/// `site_env.py` reads it). The `engine` map is free-form so any DGPP knob
+/// (kv_capacity, mtp, dflash_model, bf16_weights, …) is carried without the
+/// launcher modelling each one.
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct DgppConfig {
+    /// HTTP API port (DGPP default 18080). Also the readiness probe port.
+    #[serde(default)]
+    pub http_port: Option<u16>,
+    /// Optional pinned release for `dgpp-cluster up --release <name>`.
+    #[serde(default)]
+    pub release: Option<String>,
+    /// Path to the DGPP repo checkout on the launch nodes (where
+    /// `scripts/dgpp-cluster` lives). The generated cluster JSON + `.env` are
+    /// written here. Default `~/dgpp`.
+    #[serde(default)]
+    pub dir: Option<String>,
+    /// The DGPP `engine` block, serialized verbatim into the cluster JSON.
+    #[serde(default)]
+    pub engine: HashMap<String, serde_json::Value>,
 }
 
 impl Recipe {

+ 4 - 0
src/config/types.rs

@@ -400,6 +400,10 @@ pub enum DeployTarget {
     #[default]
     Docker,
     Tmux,
+    /// Bare SSH command on the head node (rank 0) driving an engine's own
+    /// cluster manager (DGPP's `dgpp-cluster up/down`). Head-coordinated:
+    /// the manager brings up the peers itself.
+    Native,
 }
 
 #[cfg(test)]

+ 1 - 0
src/docker/command_tests.rs

@@ -116,6 +116,7 @@ fn base_recipe() -> Recipe {
         build: Default::default(),
         composed_model: None,
         staging: vec![],
+        dgpp: None,
     }
 }
 

+ 1 - 0
src/docker/mirror.rs

@@ -214,6 +214,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 1 - 0
src/docker/sglang_tests.rs

@@ -41,6 +41,7 @@ fn sglang_recipe() -> Recipe {
         build: Default::default(),
         composed_model: None,
         staging: vec![],
+            dgpp: None,
     }
 }
 

+ 1 - 0
src/docker/tests.rs

@@ -81,6 +81,7 @@ fn trec() -> Recipe {
         build: Default::default(),
         composed_model: None,
         staging: vec![],
+            dgpp: None,
     }
 }
 

+ 1 - 0
src/doctor.rs

@@ -387,6 +387,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 30 - 1
src/engine.rs

@@ -200,6 +200,33 @@ impl EngineDriver for Gr8Driver {
     }
 }
 
+/// DGPP (`HawkBearPig/dgpp`): a C++/CUDA engine, config-JSON driven and
+/// head-coordinated — rank 0 runs `dgpp-cluster up`, which brings up the
+/// peers itself. No CLI model/distributed flags (the cluster JSON carries
+/// model + world size + engine knobs), no bind-IP env, fixed API port
+/// (:18080). `deploy = native`.
+pub struct DgppDriver;
+
+impl EngineDriver for DgppDriver {
+    // The cluster JSON carries the model; no CLI model argument.
+    fn model_args(&self, _recipe: &Recipe) -> Vec<String> {
+        Vec::new()
+    }
+
+    // World size + rendezvous live in the cluster JSON, not CLI flags.
+    fn distributed_args(&self, _ctx: &EngineFlagsCtx<'_>, _node: &Node) -> Vec<String> {
+        Vec::new()
+    }
+
+    fn takes_host_port(&self) -> bool {
+        false
+    }
+
+    fn entrypoint_default(&self) -> Vec<String> {
+        vec!["scripts/dgpp-cluster".into(), "up".into()]
+    }
+}
+
 /// Resolve the driver for a recipe's `engine` string. Unknown engines fall
 /// back to the vLLM driver (matching the historical default: an engine with no
 /// dedicated driver still gets the vLLM-style tail).
@@ -207,6 +234,7 @@ pub fn driver_for(engine: &str) -> &'static dyn EngineDriver {
     match engine {
         "sglang" => &SglangDriver,
         "gr8" => &Gr8Driver,
+        "dgpp" => &DgppDriver,
         "vllm" => &VllmDriver,
         _ => &VllmDriver,
     }
@@ -250,7 +278,7 @@ pub fn build_engine_args(ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
 }
 
 #[cfg(test)]
-mod tests {
+pub(crate) mod tests {
     use super::*;
     use crate::config::DeployTarget;
     use crate::pre_serve::{RecipeArgs, VenvSetup};
@@ -293,6 +321,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 44 - 10
src/main.rs

@@ -44,6 +44,7 @@ mod home_assistant;
 mod host_keys;
 mod locale;
 mod mikrotik;
+mod native;
 mod orchestrate;
 mod power;
 mod pre_serve;
@@ -210,16 +211,39 @@ async fn main() -> Result<()> {
             power_off,
             no_wait,
         } => {
-            crate::docker::stop::handle(
-                &config,
-                &recipe_name,
-                &cluster,
-                &node,
-                !no_wait,
-                power_off,
-                dry_run,
-            )
-            .await?;
+            // Native (DGPP) recipes stop via the engine's own cluster manager,
+            // not docker labels — route by the recipe's deploy target.
+            let recipe_obj =
+                crate::config::load_recipe(&recipe_name, &config.recipe_sources).await?;
+            if recipe_obj.deploy == crate::config::DeployTarget::Native {
+                let cname = if !cluster.is_empty() && cluster != "auto" {
+                    cluster.clone()
+                } else {
+                    recipe_obj.cluster.clone().ok_or_else(|| {
+                        anyhow::anyhow!("native stop needs --cluster (or recipe cluster)")
+                    })?
+                };
+                let cl = crate::config::select_cluster_by_name(&config, &cname)?;
+                native::native_stop(&config, &recipe_obj, &cl, dry_run).await?;
+                if !dry_run {
+                    crate::runlock::mark_stop(&recipe_name, &config.recipe_sources)?;
+                }
+                if power_off {
+                    let names: Vec<String> = cl.nodes.iter().map(|n| n.name.clone()).collect();
+                    crate::power::power_off(&config, &cl, &names, dry_run).await?;
+                }
+            } else {
+                crate::docker::stop::handle(
+                    &config,
+                    &recipe_name,
+                    &cluster,
+                    &node,
+                    !no_wait,
+                    power_off,
+                    dry_run,
+                )
+                .await?;
+            }
         }
         Commands::Bench {
             recipe,
@@ -415,6 +439,16 @@ pub(crate) async fn handle_serve(
                 .await?;
             }
         }
+        DeployTarget::Native => {
+            // Head-coordinated native launch (DGPP): the engine's cluster
+            // manager runs on rank 0 and brings up the peers itself.
+            native::native_serve(config, recipe, cluster, node.as_deref(), &opts).await?;
+            if follow && !dry_run {
+                // DGPP logs live in its own log dir on rank 0 (no docker
+                // label to tail); point the operator at the log file.
+                tracing::info!("DGPP serving; engine log on rank 0: ~/dgpp/log/sm121-{recipe}.log");
+            }
+        }
         DeployTarget::Tmux => {
             // Warn about docker-only CLI opts that are silently ignored.
             let ignored: Vec<&str> = [

+ 331 - 0
src/native/mod.rs

@@ -0,0 +1,331 @@
+//! Native (head-coordinated) deploy: an engine's own cluster manager runs on
+//! rank 0 and brings up the peers itself. Currently only DGPP
+//! (`HawkBearPig/dgpp`, `engine = "dgpp"`, `deploy = "native"`).
+//!
+//! The launcher generates DGPP's cluster JSON (model + world size + engine
+//! knobs + http port) and a sibling `.env` (node list + ssh user + ports) from
+//! the recipe + selected cluster, uploads both to rank 0's DGPP checkout, and
+//! runs `scripts/dgpp-cluster up --config <path>` there. Stop runs
+//! `scripts/dgpp-cluster down`. Readiness is the uniform `/v1/models` probe on
+//! the DGPP HTTP port (default 18080).
+
+use anyhow::Result;
+use tracing::info;
+
+use crate::config::{Cluster, Config, DgppConfig, Node, Recipe};
+use crate::docker::ServeOpts;
+
+/// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
+pub fn http_port(recipe: &Recipe) -> u16 {
+    recipe
+        .dgpp
+        .as_ref()
+        .and_then(|d| d.http_port)
+        .unwrap_or(18080)
+}
+
+/// DGPP repo checkout path on the nodes (its `[dgpp].dir`, else `~/dgpp`).
+fn dgpp_dir(d: &DgppConfig) -> String {
+    d.dir.clone().unwrap_or_else(|| "~/dgpp".to_string())
+}
+
+/// Rewrite a leading `~` to `$HOME` so the remote shell expands it (a `~`
+/// inside single quotes would be a literal directory named `~`). Callers wrap
+/// the result in double quotes.
+fn expand_home(path: &str) -> String {
+    if path == "~" {
+        "$HOME".to_string()
+    } else if let Some(rest) = path.strip_prefix("~/") {
+        format!("$HOME/{rest}")
+    } else {
+        path.to_string()
+    }
+}
+
+/// Build the DGPP cluster JSON from the recipe. `model` and `world_size` come
+/// from the recipe + launch node count; `http` from `[dgpp].http_port`; the
+/// `engine` block is the recipe's `[dgpp.engine]` map serialized verbatim.
+pub fn generate_cluster_json(recipe: &Recipe, world_size: usize) -> Result<String> {
+    let d = recipe
+        .dgpp
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("recipe has no [dgpp] block"))?;
+    let mut root = serde_json::Map::new();
+    root.insert(
+        "model".into(),
+        serde_json::Value::String(recipe.model.clone()),
+    );
+    root.insert(
+        "world_size".into(),
+        serde_json::Value::from(world_size as i64),
+    );
+    if let Some(r) = &d.release {
+        root.insert("release".into(), serde_json::Value::String(r.clone()));
+    }
+    if !d.engine.is_empty() {
+        let mut eng = serde_json::Map::new();
+        for (k, v) in &d.engine {
+            eng.insert(k.clone(), v.clone());
+        }
+        root.insert("engine".into(), serde_json::Value::Object(eng));
+    }
+    let mut http = serde_json::Map::new();
+    http.insert(
+        "bind_host".into(),
+        serde_json::Value::String("0.0.0.0".into()),
+    );
+    http.insert(
+        "port".into(),
+        serde_json::Value::from(http_port(recipe) as i64),
+    );
+    root.insert("http".into(), serde_json::Value::Object(http));
+    Ok(serde_json::to_string_pretty(&serde_json::Value::Object(
+        root,
+    ))?)
+}
+
+/// Build the DGPP site `.env` (allowlisted KEY=value lines DGPP's site_env.py
+/// reads): the launch nodes (ssh_ip, rank order), the ssh user, and the ports.
+pub fn generate_site_env(ssh_user: &str, cluster: &Cluster, recipe: &Recipe) -> String {
+    let nodes = cluster
+        .nodes
+        .iter()
+        .map(|n| n.ssh_ip.clone())
+        .collect::<Vec<_>>()
+        .join(",");
+    let port = http_port(recipe);
+    format!(
+        "DGPP_NODES={nodes}\nDGPP_SSH_USER={user}\nDGPP_HTTP_PORT={port}\nDGPP_FABRIC_PORT=29970\nDGPP_JOURNAL_PORT=29971\n",
+        user = ssh_user,
+    )
+}
+
+/// Write a file on `node` via a single-quoted heredoc (content has no line
+/// equal to the delimiter — JSON/env never do). `path` is already `$HOME`-
+/// expanded and is wrapped in double quotes so it expands on the remote shell.
+async fn write_remote(
+    config: &Config,
+    node: &Node,
+    path: &str,
+    content: &str,
+    dry_run: bool,
+) -> Result<()> {
+    let cmd = format!("cat > \"{path}\" <<'SM121DGPP'\n{content}\nSM121DGPP\n");
+    crate::ssh::run_ssh(config, node, cmd, dry_run).await?;
+    Ok(())
+}
+
+/// Serve a DGPP recipe: select the cluster, run before_serve hooks, upload the
+/// generated cluster JSON + `.env` to rank 0, and launch `dgpp-cluster up`
+/// there (head-coordinated — the manager brings up the peers).
+pub async fn native_serve(
+    config: &Config,
+    recipe_name: &str,
+    cluster_name: &str,
+    node_name: Option<&str>,
+    opts: &ServeOpts,
+) -> Result<Cluster> {
+    let dry_run = opts.dry_run;
+    let recipe_obj = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
+    let dgpp = recipe_obj
+        .dgpp
+        .clone()
+        .ok_or_else(|| anyhow::anyhow!("engine=dgpp requires a [dgpp] block"))?;
+
+    let (mut cluster, _nccl) = if dry_run {
+        crate::orchestrate::prepare_cluster_dry_run(config, &recipe_obj, cluster_name, node_name)?
+    } else {
+        use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
+        use crate::orchestrate::{BootTiming, PrepareRequest, prepare_cluster};
+        let power = opts
+            .auto_power_on
+            .then(|| ha_controller_from_config(config))
+            .flatten();
+        prepare_cluster(
+            config,
+            &recipe_obj,
+            cluster_name,
+            node_name,
+            &PrepareRequest {
+                prober: &SshProber,
+                power: power.as_ref(),
+                pinger: &SshNodePinger,
+                auto_power_on: opts.auto_power_on,
+                boot_timing: BootTiming::default(),
+                force_occupied: false,
+            },
+        )
+        .await?
+    };
+    // Trim to the launch set (world size = required_nodes).
+    if let Some(n) = recipe_obj.required_nodes
+        && (n as usize) < cluster.nodes.len()
+    {
+        cluster.nodes.truncate(n as usize);
+    }
+    let head = cluster
+        .nodes
+        .iter()
+        .find(|n| n.rank == 0)
+        .cloned()
+        .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
+
+    crate::before_serve::run(
+        config,
+        &cluster,
+        &recipe_obj,
+        opts.skip_before_serve,
+        dry_run,
+    )
+    .await?;
+
+    let dir = expand_home(&dgpp_dir(&dgpp));
+    let cfg_name = format!("cluster_sm121_{recipe_name}.json");
+    let cfg_path = format!("{dir}/{cfg_name}");
+    let env_path = format!("{dir}/.env");
+    let json = generate_cluster_json(&recipe_obj, cluster.nodes.len())?;
+    let env = generate_site_env(&config.ssh_user, &cluster, &recipe_obj);
+
+    crate::ssh::run_ssh(
+        config,
+        &head,
+        format!("mkdir -p \"{dir}\" && mkdir -p \"{dir}/log\""),
+        dry_run,
+    )
+    .await?;
+    write_remote(config, &head, &cfg_path, &json, dry_run).await?;
+    write_remote(config, &head, &env_path, &env, dry_run).await?;
+
+    // Launch on rank 0 only; the DGPP manager SSHes to the peers. Detached so
+    // it survives the SSH session (nohup + log file).
+    let up = format!(
+        "cd \"{dir}\" && set -a && . ./.env && set +a && nohup scripts/dgpp-cluster up --config '{cfg_name}' > 'log/sm121-{recipe_name}.log' 2>&1 &"
+    );
+    info!("🚀 {} (dgpp, head {})", recipe_obj.name, head.name);
+    crate::ssh::run_ssh(config, &head, up, dry_run).await?;
+
+    if !dry_run {
+        crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
+        if !opts.no_wait_ready
+            && let Err(e) = crate::readiness::wait_engine(
+                config,
+                &cluster,
+                http_port(&recipe_obj),
+                1800,
+                recipe_name,
+            )
+            .await
+        {
+            let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
+            return Err(e);
+        }
+    }
+    Ok(cluster)
+}
+
+/// Stop a DGPP run: `dgpp-cluster down` on rank 0 (the manager stops the
+/// peers), then the caller closes the ledger.
+pub async fn native_stop(
+    config: &Config,
+    recipe: &Recipe,
+    cluster: &Cluster,
+    dry_run: bool,
+) -> Result<()> {
+    let Some(d) = recipe.dgpp.as_ref() else {
+        anyhow::bail!("engine=dgpp requires a [dgpp] block to stop");
+    };
+    let dir = expand_home(&dgpp_dir(d));
+    let head = cluster
+        .nodes
+        .iter()
+        .find(|n| n.rank == 0)
+        .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
+    let down = format!("cd \"{dir}\" && scripts/dgpp-cluster down");
+    crate::ssh::run_ssh(config, head, down, dry_run).await?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{DgppConfig, Node};
+    use serde_json::json;
+
+    fn dgpp_recipe() -> Recipe {
+        let mut r = crate::engine::tests::base_recipe();
+        r.engine = "dgpp".into();
+        r.name = "dgpp-qwen".into();
+        r.model = "unsloth/Qwen3.8-27B-NVFP4".into();
+        r.port = 18080;
+        r.required_nodes = Some(1);
+        r.deploy = crate::config::DeployTarget::Native;
+        r.dgpp = Some(DgppConfig {
+            http_port: Some(18080),
+            engine: [
+                ("kv_capacity".to_string(), json!(262144)),
+                ("mtp".to_string(), json!(true)),
+                (
+                    "dflash_model".to_string(),
+                    json!("z-lab/Qwen3.8-27B-DFlash2"),
+                ),
+            ]
+            .into_iter()
+            .collect(),
+            ..Default::default()
+        });
+        r
+    }
+
+    #[test]
+    fn cluster_json_carries_model_world_size_engine_and_http() {
+        let r = dgpp_recipe();
+        let json: serde_json::Value =
+            serde_json::from_str(&generate_cluster_json(&r, 1).unwrap()).unwrap();
+        assert_eq!(json["model"], "unsloth/Qwen3.8-27B-NVFP4");
+        assert_eq!(json["world_size"], 1);
+        assert_eq!(json["engine"]["kv_capacity"], 262144);
+        assert_eq!(json["engine"]["mtp"], true);
+        assert_eq!(json["engine"]["dflash_model"], "z-lab/Qwen3.8-27B-DFlash2");
+        assert_eq!(json["http"]["port"], 18080);
+        assert_eq!(json["http"]["bind_host"], "0.0.0.0");
+    }
+
+    #[test]
+    fn expand_home_rewrites_leading_tilde() {
+        assert_eq!(expand_home("~/dgpp"), "$HOME/dgpp");
+        assert_eq!(expand_home("~"), "$HOME");
+        assert_eq!(expand_home("/srv/dgpp"), "/srv/dgpp");
+    }
+
+    #[test]
+    fn site_env_lists_nodes_ssh_user_and_ports() {
+        let r = dgpp_recipe();
+        let cluster = Cluster {
+            name: "dgx1".into(),
+            master_ib_ip: None,
+            nodes: vec![
+                Node {
+                    name: "dgx1".into(),
+                    ssh_ip: "192.168.88.10".into(),
+                    ib_ip: None,
+                    rank: 0,
+                    host_key: None,
+                    entity: None,
+                },
+                Node {
+                    name: "dgx2".into(),
+                    ssh_ip: "192.168.88.14".into(),
+                    ib_ip: None,
+                    rank: 1,
+                    host_key: None,
+                    entity: None,
+                },
+            ],
+            extra_entities: vec![],
+        };
+        let env = generate_site_env("kilork", &cluster, &r);
+        assert!(env.contains("DGPP_NODES=192.168.88.10,192.168.88.14"));
+        assert!(env.contains("DGPP_SSH_USER=kilork"));
+        assert!(env.contains("DGPP_HTTP_PORT=18080"));
+    }
+}

+ 1 - 0
src/staging.rs

@@ -272,6 +272,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }
 

+ 1 - 0
src/tmux/commands.rs

@@ -178,6 +178,7 @@ mod tests {
             build: Default::default(),
             composed_model: None,
             staging: vec![],
+            dgpp: None,
         }
     }