Selaa lähdekoodia

feat: SGLang engine support + minimax-m3 recipe + README

- Add engine-aware Docker launcher: SGLang uses --tp, --model-path,
  SGLANG_WORKER_IP instead of vLLM flags
- Add engine-aware tmux launcher: same treatment for tmux runtime
- Add tensor_parallel_size field to Recipe (default: 1, backward compat)
- Auto-generate --port flag for SGLang nodes (all nodes, not just head)
- Add recipes/minimax-m3/recipe.toml for sparkarena/Minimax-M3-v0-NVFP4
  with scitrera/dgx-spark-sglang-mm:v0 container, TP=4, port 30000
- Update README.md with recipe docs, engine comparison table, CLI reference
- Run cargo fmt across entire codebase
- All 25 tests pass, clippy clean
Alexander Korolev 4 kuukautta sitten
vanhempi
sitoutus
303e0ce9be
12 muutettua tiedostoa jossa 905 lisäystä ja 246 poistoa
  1. 45 12
      AGENTS.md
  2. 189 36
      README.md
  3. 29 0
      recipes/minimax-m3/recipe.toml
  4. 13 3
      src/cli.rs
  5. 16 2
      src/config.rs
  6. 399 79
      src/docker.rs
  7. 49 22
      src/doctor.rs
  8. 5 9
      src/main.rs
  9. 32 19
      src/ssh.rs
  10. 111 56
      src/tmux/mod.rs
  11. 5 5
      src/tmux/ops.rs
  12. 12 3
      src/tmux/serve.rs

+ 45 - 12
AGENTS.md

@@ -27,6 +27,13 @@ cargo run logs --recipe glm51                              # Last 100 lines per
 cargo run logs --recipe glm51 --follow                     # Follow head node logs
 cargo run doctor --recipe glm51                            # Check node prerequisites
 cargo run doctor --recipe glm51 --repair                   # Auto-fix (build repaired image)
+
+### SGLang recipe example
+```
+cargo run serve --recipe minimax-m3                        # SGLang, 4 nodes, TP=4
+cargo run -- -d serve --recipe minimax-m3                  # Dry-run (see the commands)
+cargo run stop --recipe minimax-m3                         # Stop SGLang containers
+cargo run logs --recipe minimax-m3                         # Check SGLang logs
 ```
 
 ### Tmux runtime
@@ -84,7 +91,9 @@ docker_image = "my-image:tag"
 model = "org/model-name"
 port = 8000
 required_nodes = 4                     # optional: 0 = any cluster size, omitted = no check
-pipeline_parallel_size = 0             # 0 = auto (equals nnodes)
+pipeline_parallel_size = 0             # 0 = auto (equals nnodes), only for vLLM
+tensor_parallel_size = 1               # for vLLM: per-node TP (= 1 for single-GPU nodes);
+                                       # for SGLang: total TP across all GPUs (= nnodes * GPUs_per_node)
 distributed_backend = "mp"             # "mp" (multiprocessing) or "ray"
 cluster = "4nodes-switch"              # optional: pin to a specific cluster
 
@@ -102,17 +111,29 @@ SOME_VAR = "value"
 
 ### Engine support
 
-Both `vllm` and `sglang` engines are supported. Engine name is used at runtime for host IP variables:
-- `vllm` → `VLLM_HOST_IP`
-- `sglang` → `SGLANG_WORKER_IP`
+Both `vllm` and `sglang` engines are supported. The engine field controls:
+
+**Host IP env var:**
+- `vllm` → `-e VLLM_HOST_IP=<node.ib_ip>`
+- `sglang` → `-e SGLANG_WORKER_IP=<node.ib_ip>`
+
+**Model argument:**
+- `vllm` → bare model name (e.g. `org/model`)
+- `sglang` → `--model-path org/model`
+
+**Distributed flags:**
+- `vllm` → `--tensor-parallel-size 1`, `--pipeline-parallel-size <pp_size>`, `--distributed-executor-backend mp|ray`
+- `sglang` → `--tp <tensor_parallel_size>`, no `--pipeline-parallel-size`, no `--distributed-executor-backend`
+
+Both engines use `--nnodes`, `--node-rank`, and `--master-addr`.
 
 ## How Docker launch works
 
-`build_docker_command` in `main.rs` constructs the `docker run` command for each node. Flavour depends on `distributed_backend`:
+`build_docker_command` in `src/docker.rs` constructs the `docker run` command for each node. It is engine-aware:
 
-### MP backend (default)
+### vLLM engine (default)
 
-Always appends these flags (cannot be overridden by recipe):
+Appends these flags (cannot be overridden by recipe):
 
 ```
 --tensor-parallel-size 1
@@ -120,24 +141,36 @@ Always appends these flags (cannot be overridden by recipe):
 --nnodes <total>
 --node-rank <rank>
 --master-addr <cluster.master_ib_ip>
---distributed-executor-backend mp
+--distributed-executor-backend mp|ray
 ```
 
-### Ray backend
+### SGLang engine
 
 Appends:
 
 ```
---tensor-parallel-size <nnodes>
+--tp <tensor_parallel_size>   # from recipe (e.g. 4 for 4 × single-GPU nodes)
+--nnodes <total>
 --node-rank <rank>
 --master-addr <cluster.master_ib_ip>
 ```
 
-Plus `-e RAY_ADDRESS=<master_ib_ip>:6379` and `-e RAY_PORT=<port>`.
+The host IP env var is `SGLANG_WORKER_IP` and the model is passed as `--model-path <model>`.
+
+### Ray backend
+
+For vLLM Ray backend, additionally appends:
+
+```
+-e RAY_ADDRESS=<master_ib_ip>:6379
+-e RAY_PORT=<port>
+```
+
+(For SGLang with Ray backend, the container's entrypoint must handle Ray setup; SGLang's native multi-node uses `mp` backend with NCCL.)
 
 ### Common to all runs
 
-NCCL env vars come from the cluster's `[nccl."<name>"]` section in `config.toml`. Port mapping (`-p`) is applied on **all nodes** (not just head).
+NCCL env vars come from the cluster's `[nccl."<name>"]` section in `config.toml`. Port mapping (`-p`) is applied on **head node only**.
 
 Containers are labelled with:
 - `sm121.recipe=<name>`

+ 189 - 36
README.md

@@ -1,16 +1,16 @@
 # sm121-launcher
 
-Rust CLI tool for launching vLLM/SGLang containers across a DGX Spark cluster via SSH.
+Rust CLI tool for launching vLLM / SGLang containers across a DGX Spark cluster via SSH.
 
 ## Features
 
-- Launch distributed LLM inference clusters with a single command
-- Support for vLLM and SGLang engines
-- Multi-cluster support with auto-selection
-- SSH-based provisioning — no agent required on target nodes
-- Docker-in-Docker deployment via SSH exec
-- Health checks, log tailing, and clean cluster teardown
-- Dry-run mode to preview commands without execution
+- Launch distributed LLM inference clusters on 2–4 DGX Spark machines
+- **vLLM** and **SGLang** engines — auto-generates the right distributed flags for each
+- Docker (default) or **tmux** (alternative) runtime
+- SSH-based provisioning via `russh` — no agent required on target nodes
+- Cluster health checks, container log tailing, and clean teardown
+- **Doctor mode** — pre-flight checks and auto-repair of broken container images
+- Dry-run (`-d`) to preview every SSH command without execution
 
 ## Quick Start
 
@@ -18,31 +18,84 @@ Rust CLI tool for launching vLLM/SGLang containers across a DGX Spark cluster vi
 # Build
 cargo build --release
 
-# Launch a recipe with auto-cluster selection
-cargo run serve --recipe glm51
+# Launch a recipe (auto-selects cluster from config.toml)
+cargo run serve --recipe glm51                # vLLM — GLM-5.1 NVFP8
+cargo run serve --recipe minimax-m3            # SGLang — Minimax-M3 NVFP4
 
 # Launch on a specific cluster
 cargo run serve --recipe glm51 --cluster 4nodes-switch
 
 # Dry-run (print SSH commands without executing)
-cargo run -- -d serve --recipe glm51
+cargo run -- -d serve --recipe minimax-m3
 
 # View cluster logs
-cargo run logs --recipe glm51                # Last 100 lines per node
-cargo run logs --recipe glm51 --follow       # Follow head node logs
+cargo run logs --recipe glm51                  # Last 100 lines per node
+cargo run logs --recipe minimax-m3 --follow    # Stream head node logs
 
-# Stop all containers on the cluster
+# Stop all containers for a recipe
 cargo run stop --recipe glm51
 
 # Health check
-cargo run health                                     # All clusters
-cargo run health --cluster 2nodes-dac               # Single cluster
+cargo run health                               # All clusters
+cargo run health --cluster 2nodes-dac          # Single cluster
+cargo run health --fingerprint                 # Discover host keys for config.toml
+
+# Doctor mode — check prerequisites
+cargo run doctor --recipe minimax-m3           # Check node prerequisites
+cargo run doctor --recipe minimax-m3 --repair  # Auto-fix (build repaired image)
+
+# Tmux runtime (no Docker)
+cargo run tmux serve --recipe glm51
+cargo run tmux status --recipe glm51
+cargo run tmux attach --recipe glm51
+```
+
+## Available Recipes
+
+### [`glm51`](recipes/glm51/recipe.toml) — vLLM (GLM-5.1 NVFP8)
+
+```toml
+engine = "vllm"
+docker_image = "vllm/vllm-openai:glm51-cu130"
+model = "lukealonso/GLM-5.1-NVFP4"
+port = 30000
+required_nodes = 4
+distributed_backend = "mp"
+```
+
+Launches GLM-5.1 with vLLM across 4 nodes. Uses pipeline parallelism (PP=4, TP=1 per node) with the `mp` distributed backend. Extra flags enable MTP speculative decoding, TRITON_MLA attention, prefix caching, and chunked prefill.
+
+```bash
+cargo run serve --recipe glm51
+cargo run logs --recipe glm51 --follow
+```
+
+### [`minimax-m3`](recipes/minimax-m3/recipe.toml) — SGLang (Minimax-M3-v0 NVFP4)
+
+```toml
+engine = "sglang"
+docker_image = "scitrera/dgx-spark-sglang-mm:v0"
+model = "sparkarena/Minimax-M3-v0-NVFP4"
+port = 30000
+required_nodes = 4
+tensor_parallel_size = 4
+distributed_backend = "mp"
+entrypoint = ["sglang", "serve"]
+```
+
+Launches Minimax-M3 with SGLang across 4 nodes. Uses tensor parallelism across all GPUs (TP=4, 1 GPU per node). NVFP4 quantization with the `cutlass` fp4 gemm backend and `triton` MoE runner. Uses `instanttensor` load format for fast model loading.
+
+```bash
+cargo run serve --recipe minimax-m3
+cargo run logs --recipe minimax-m3
 ```
 
 ## Configuration
 
 ### config.toml
 
+Create a `config.toml` in the project root (not tracked in git):
+
 ```toml
 ssh_user = "kilork"
 ssh_key_path = "/path/to/ssh/key"
@@ -51,8 +104,10 @@ ssh_key_path = "/path/to/ssh/key"
 name = "4nodes-switch"
 master_ib_ip = "192.168.200.15"
 nodes = [
-    { name = "dgx1.lan", ssh_ip = "192.168.88.14", ib_ip = "192.168.200.15", rank = 0 },
-    { name = "dgx2.lan", ssh_ip = "192.168.88.10", ib_ip = "192.168.200.13", rank = 1 },
+    { name = "dgx1", ssh_ip = "192.168.88.14", ib_ip = "192.168.200.15", rank = 0 },
+    { name = "dgx2", ssh_ip = "192.168.88.10", ib_ip = "192.168.200.13", rank = 1 },
+    { name = "dgx3", ssh_ip = "192.168.88.50", ib_ip = "192.168.200.17", rank = 2 },
+    { name = "dgx4", ssh_ip = "192.168.88.51", ib_ip = "192.168.200.19", rank = 3 },
 ]
 
 [nccl."4nodes-switch"]
@@ -62,18 +117,35 @@ ib_gid_index = 3
 ib_disable = 0
 ```
 
-### Recipes
+| Field | Purpose |
+|---|---|
+| `ssh_user` / `ssh_key_path` | SSH credential for all nodes |
+| `clusters[].master_ib_ip` | InfiniBand IP of the head node (rank 0) |
+| `nodes[].ssh_ip` | SSH reachable IP (usually Ethernet) |
+| `nodes[].ib_ip` | InfiniBand / RoCE IP for NCCL communication |
+| `nodes[].rank` | Node rank (0 = head, 1..N = workers) |
+| `nodes[].host_key` | Optional SHA-256 fingerprint — run `cargo run health --fingerprint` to discover |
+| `nccl."<name>".*` | NCCL environment variables for the cluster's IB fabric |
+
+Multiple clusters can be defined; `--cluster auto` picks the first one with an NCCL section.
 
-Recipes live in `recipies/<name>/recipe.toml` (note the directory spelling).
+### Creating a recipe
+
+Recipes live in `recipes/<name>/recipe.toml` (directory name intentionally misspelled).
 
 ```toml
-engine = "vllm"
-name = "GLM-5.1 NVFP8"
-docker_image = "vllm/vllm-openai:glm51-cu130"
-model = "lukealonso/GLM-5.1-NVFP4"
+engine = "vllm"                        # or "sglang" (default: "vllm")
+name = "Human Readable Name"
+docker_image = "my-image:tag"
+model = "org/model-name"
 port = 30000
-required_nodes = 4
-pipeline_parallel_size = 0    # 0 = auto (equals nnodes)
+required_nodes = 4                     # optional, omitted = no check
+pipeline_parallel_size = 0             # 0 = auto (equals nnodes), vLLM only
+tensor_parallel_size = 1               # per-node TP for vLLM, total TP for SGLang
+distributed_backend = "mp"             # "mp" (multiprocessing) or "ray"
+cluster = "4nodes-switch"              # optional: pin to a specific cluster
+
+entrypoint = ["sglang", "serve"]       # optional, appended after image name
 
 args = [
     "--kv-cache-dtype", "fp8",
@@ -81,26 +153,107 @@ args = [
 ]
 
 [env]
-NCCL_DEBUG = "WARN"
+SOME_VAR = "value"
 ```
 
+#### Engine-specific behavior
+
+| Aspect | vLLM | SGLang |
+|---|---|---|
+| Host IP env var | `VLLM_HOST_IP` | `SGLANG_WORKER_IP` |
+| Model argument | bare model name | `--model-path <model>` |
+| TP flag | `--tensor-parallel-size 1` | `--tp <tensor_parallel_size>` |
+| PP flag | `--pipeline-parallel-size <pp_size>` | _(not used)_ |
+| Backend flag | `--distributed-executor-backend mp|ray` | _(not used)_ |
+| Common flags | `--nnodes`, `--node-rank`, `--master-addr` | same |
+| Port flag | _(recipe args)_ | `--port <port>` (auto-generated) |
+| Entrypoint | `python -m vllm.entrypoints.openai.api_server` | recipe-defined (e.g. `sglang serve`) |
+
 ## Launch Behavior
 
-For each node, the tool builds and executes a `docker run` command via SSH with:
-- Full GPU access (`--gpus all --ipc=host --privileged --network=host`)
-- NCCL env vars from the cluster's `[nccl."<name>"]` section
-- Recipe-defined env vars and args
-- Appended multi-node flags: `--tensor-parallel-size 1`, `--pipeline-parallel-size`, `--nnodes`, `--node-rank`, `--master-addr`, `--distributed-executor-backend mp`
+### Docker runtime
+
+For each node, the tool builds a `docker run` command via SSH:
+
+```
+docker run -d --gpus all --ipc=host --privileged --network=host \
+  -e NCCL_IB_DISABLE=<...> -e NCCL_IB_HCA=<...> ... \
+  -e SGLANG_WORKER_IP=<node.ib_ip> \       # or VLLM_HOST_IP
+  -e <recipe.env>... \
+  [-p <port>:<port>] \                       # head node only
+  --name sm121_<recipe>_<rank> \
+  --label sm121.recipe=<recipe> \
+  --label sm121.rank=<rank> \
+  <docker_image> \
+  <entrypoint>... \                          # e.g. sglang serve
+  [--model-path] <model> \                   # engine-specific
+  <recipe.args>... \                         # from recipe
+  --tp <tensor_parallel_size> \              # engine-specific flags
+  --nnodes <nnodes> \
+  --node-rank <rank> \
+  --master-addr <master_ib_ip>
+  [--port <port>]                            # SGLang only
+```
+
+NCCL env vars come from the cluster's `[nccl."<name>"]` section. All containers use `--network=host` for direct InfiniBand access.
 
-`--cluster auto` selects the first cluster in config with an NCCL section.
+### Tmux runtime
+
+`cargo run tmux serve --recipe <name>` creates a 3-window tmux session on each node:
+
+| Window | Content |
+|--------|---------|
+| 0: `model` | Main inference process (vLLM/SGLang) |
+| 1: `gpu` | `nvidia-smi -l 5` GPU monitor |
+| 2: `shell` | Debug shell |
+
+For Ray backend, nodes start sequentially (head first with API readiness poll up to 150s). For MP backend, all nodes start in parallel.
+
+## Doctor Mode
+
+```bash
+cargo run doctor --recipe minimax-m3           # Check node prerequisites
+cargo run doctor --recipe minimax-m3 --repair  # Auto-fix issues
+```
+
+Checks each node for:
+- Docker availability
+- NVIDIA GPU (`nvidia-smi`)
+- NCCL libraries
+- Engine version inside the image (`vllm version` / `sglang version`)
+- Ray availability (if backend is "ray")
+
+With `--repair`, generates a Dockerfile that installs missing packages and rebuilds the image with a `.repaired` tag. The repaired image is auto-detected on subsequent `serve` runs.
+
+## CLI Reference
+
+```text
+Usage: sm121-launcher [OPTIONS] <COMMAND>
+
+Commands:
+  serve    Launch a recipe on a cluster
+  logs     Show container logs
+  stop     Stop and remove containers for a recipe
+  health   Check cluster health (or fingerprint host keys)
+  doctor   Check and repair node prerequisites
+  tmux     Manage tmux sessions (alternative to Docker)
+
+Global flags:
+  -c, --config <FILE>    Config file path (default: config.toml)
+  -d, --dry-run          Print commands without executing
+  -h, --help             Print help
+  -V, --version          Print version
+```
 
 ## Dependencies
 
 | Crate | Purpose |
 |---|---|
-| `clap` 4.6 | CLI argument parsing (derive) |
+| `clap` 4.6 + derive | CLI argument parsing |
 | `russh` 0.60 | SSH client |
 | `tokio` full | Async runtime |
-| `toml` 1 + `serde` 1 | Config/recipe deserialization |
-| `tracing` | Structured logging |
+| `toml` 1 + `serde` 1 | Config / recipe deserialization |
+| `tracing` + `tracing-subscriber` | Structured logging |
 | `anyhow` | Error handling |
+| `futures` 0.3 | Concurrent SSH (`join_all`) |
+| `sha2` 0.10 + `base64` 0.22 | Host key fingerprinting |

+ 29 - 0
recipes/minimax-m3/recipe.toml

@@ -0,0 +1,29 @@
+engine = "sglang"
+name = "Minimax-M3-v0 NVFP4"
+docker_image = "scitrera/dgx-spark-sglang-mm:v0"
+model = "sparkarena/Minimax-M3-v0-NVFP4"
+port = 30000
+required_nodes = 4
+tensor_parallel_size = 4
+distributed_backend = "mp"
+
+entrypoint = ["sglang", "serve"]
+
+args = [
+    "--mem-fraction-static", "0.8",
+    "--context-length", "200000",
+    "--chunked-prefill-size", "8192",
+    "--fp4-gemm-backend", "cutlass",
+    "--load-format", "instanttensor",
+    "--kv-cache-dtype", "auto",
+    "--reasoning-parser", "minimax-m3",
+    "--tool-call-parser", "minimax-m3",
+    "--moe-runner-backend", "triton",
+    "--trust-remote-code",
+    "--cuda-graph-max-bs", "16",
+    "--cuda-graph-bs", "1", "2", "4", "8", "12", "16",
+    "--host", "0.0.0.0",
+]
+
+[env]
+TORCH_NCCL_ASYNC_ERROR_HANDLING = "1"

+ 13 - 3
src/cli.rs

@@ -46,7 +46,10 @@ pub enum Commands {
             help = "Имя кластера (например, 4nodes-switch). Если не указано - проверяет все."
         )]
         cluster: Option<String>,
-        #[arg(long, help = "Показать хост-ключи узлов (для добавления в config.toml)")]
+        #[arg(
+            long,
+            help = "Показать хост-ключи узлов (для добавления в config.toml)"
+        )]
         fingerprint: bool,
     },
 
@@ -54,9 +57,16 @@ pub enum Commands {
     Doctor {
         #[arg(long, required = true, help = "Имя рецепта для проверки")]
         recipe: String,
-        #[arg(long, default_value = "auto", help = "Кластер (если не auto — использует recipe.cluster)")]
+        #[arg(
+            long,
+            default_value = "auto",
+            help = "Кластер (если не auto — использует recipe.cluster)"
+        )]
         cluster: String,
-        #[arg(long, help = "Автоматически исправить проблемы (создать Dockerfile + собрать образ)")]
+        #[arg(
+            long,
+            help = "Автоматически исправить проблемы (создать Dockerfile + собрать образ)"
+        )]
         repair: bool,
     },
 

+ 16 - 2
src/config.rs

@@ -52,6 +52,8 @@ pub struct Recipe {
     pub required_nodes: Option<u32>,
     #[serde(default)]
     pub pipeline_parallel_size: Option<u32>,
+    #[serde(default = "default_tensor_parallel_size")]
+    pub tensor_parallel_size: u32,
     #[serde(default)]
     pub args: Vec<String>,
     #[serde(default)]
@@ -72,9 +74,16 @@ fn default_distributed_backend() -> String {
     "mp".to_string()
 }
 
+fn default_tensor_parallel_size() -> u32 {
+    1
+}
+
 /// Selects a cluster and its NCCL config by name, or auto-picks the first
 /// cluster that has NCCL config.
-pub fn select_cluster<'c>(config: &'c Config, cluster_name: &str) -> Result<(Cluster, &'c NcclConfig)> {
+pub fn select_cluster<'c>(
+    config: &'c Config,
+    cluster_name: &str,
+) -> Result<(Cluster, &'c NcclConfig)> {
     if cluster_name != "auto" {
         let cluster = config
             .clusters
@@ -133,7 +142,10 @@ pub async fn load_recipe(recipe_name: &str) -> Result<Recipe> {
 
 /// Returns the cluster and recipe name for a recipe (for stop/logs commands
 /// that only receive a recipe name).
-pub async fn resolve_cluster_for_recipe(config: &Config, recipe_name: &str) -> Result<(Cluster, Recipe)> {
+pub async fn resolve_cluster_for_recipe(
+    config: &Config,
+    recipe_name: &str,
+) -> Result<(Cluster, Recipe)> {
     let recipe = load_recipe(recipe_name).await?;
     let (cluster, _) = resolve_cluster(config, &recipe, "auto")?;
     Ok((cluster, recipe))
@@ -191,6 +203,7 @@ model = "test/model"
         assert!(recipe.args.is_empty());
         assert!(recipe.env.is_empty());
         assert_eq!(recipe.distributed_backend, "mp");
+        assert_eq!(recipe.tensor_parallel_size, 1);
     }
 
     #[test]
@@ -220,6 +233,7 @@ OTHER = "123"
         assert_eq!(recipe.port, 30000);
         assert_eq!(recipe.required_nodes, Some(4));
         assert_eq!(recipe.pipeline_parallel_size, Some(2));
+        assert_eq!(recipe.tensor_parallel_size, 1);
         assert_eq!(recipe.distributed_backend, "ray");
         assert_eq!(recipe.cluster, Some("my-cluster".to_string()));
         assert_eq!(recipe.args, vec!["--arg1", "val1", "--arg2"]);

+ 399 - 79
src/docker.rs

@@ -7,21 +7,37 @@ use crate::doctor::try_repaired_image;
 use crate::ssh::*;
 
 /// Launches containers across the cluster for a recipe.
-pub async fn serve(config: &Config, recipe_name: &str, cluster_name: &str, dry_run: bool) -> Result<()> {
+pub async fn serve(
+    config: &Config,
+    recipe_name: &str,
+    cluster_name: &str,
+    dry_run: bool,
+) -> Result<()> {
     let recipe = load_recipe(recipe_name).await?;
-    info!("🚀 Запуск рецепта: {} ({}) [backend: {}]", recipe.name, recipe.engine, recipe.distributed_backend);
+    info!(
+        "🚀 Запуск рецепта: {} ({}) [backend: {}]",
+        recipe.name, recipe.engine, recipe.distributed_backend
+    );
     let (cluster, nccl) = resolve_cluster(config, &recipe, cluster_name)?;
 
     if let Some(required) = recipe.required_nodes {
         let actual = cluster.nodes.len() as u32;
         if actual != required {
-            anyhow::bail!("Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes", recipe.name, required, cluster.name, actual);
+            anyhow::bail!(
+                "Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes",
+                recipe.name,
+                required,
+                cluster.name,
+                actual
+            );
         }
     }
 
     let image = try_repaired_image(config, &cluster, &recipe).await?;
     let mut recipe = recipe;
-    if image != recipe.docker_image { recipe.docker_image = image.clone(); }
+    if image != recipe.docker_image {
+        recipe.docker_image = image.clone();
+    }
 
     start_cluster(config, &cluster, nccl, recipe_name, &recipe, dry_run).await?;
     if !dry_run {
@@ -32,25 +48,48 @@ pub async fn serve(config: &Config, recipe_name: &str, cluster_name: &str, dry_r
 }
 
 /// Shows Docker logs for a recipe across all nodes (or follows head node).
-pub async fn show_logs(config: &Config, cluster: &Cluster, recipe_name: &str, follow: bool, dry_run: bool) -> Result<()> {
+pub async fn show_logs(
+    config: &Config,
+    cluster: &Cluster,
+    recipe_name: &str,
+    follow: bool,
+    dry_run: bool,
+) -> Result<()> {
     if follow {
         let head_node = cluster.nodes.first().context("No nodes in cluster")?;
         info!("📜 Following logs for '{}' (head node)...", head_node.name);
-        let cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank=0\" | xargs -r docker logs -f", recipe_name);
+        let cmd = format!(
+            "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank=0\" | xargs -r docker logs -f",
+            recipe_name
+        );
         run_ssh_follow(config, head_node, cmd, dry_run).await?;
     } else {
         let flag = "--tail=100";
-        info!("📜 Логи кластера '{}' ({} узлов):", cluster.name, cluster.nodes.len());
+        info!(
+            "📜 Логи кластера '{}' ({} узлов):",
+            cluster.name,
+            cluster.nodes.len()
+        );
         let mut handles = Vec::new();
         for node in &cluster.nodes {
-            let cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank={}\" | xargs -r docker logs {} 2>&1", recipe_name, node.rank, flag);
-            let config = config.clone(); let node = node.clone(); let dry = dry_run;
+            let cmd = format!(
+                "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank={}\" | xargs -r docker logs {} 2>&1",
+                recipe_name, node.rank, flag
+            );
+            let config = config.clone();
+            let node = node.clone();
+            let dry = dry_run;
             let name = node.name.clone();
-            handles.push(tokio::spawn(async move { (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await) }));
+            handles.push(tokio::spawn(async move {
+                (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
+            }));
         }
         for result in futures::future::join_all(handles).await {
-            if let Ok((name, Ok(output))) = result && !output.trim().is_empty() {
-                println!("\n--- {} ---", name); println!("{}", output);
+            if let Ok((name, Ok(output))) = result
+                && !output.trim().is_empty()
+            {
+                println!("\n--- {} ---", name);
+                println!("{}", output);
             }
         }
     }
@@ -58,17 +97,28 @@ pub async fn show_logs(config: &Config, cluster: &Cluster, recipe_name: &str, fo
 }
 
 /// Stops and removes containers for a recipe on all nodes.
-pub async fn stop_cluster(config: &Config, cluster: &Cluster, recipe_name: &str, dry_run: bool) -> Result<()> {
+pub async fn stop_cluster(
+    config: &Config,
+    cluster: &Cluster,
+    recipe_name: &str,
+    dry_run: bool,
+) -> Result<()> {
     info!("🛑 Останавливаем кластер '{}'...", cluster.name);
     let mut failures = Vec::new();
 
     for node in &cluster.nodes {
-        let stop_cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" | xargs -r docker stop", recipe_name);
+        let stop_cmd = format!(
+            "docker ps -q --filter \"label=sm121.recipe={}\" | xargs -r docker stop",
+            recipe_name
+        );
         if let Err(e) = run_ssh(config, node, stop_cmd, dry_run).await {
             tracing::warn!("Stop failed on {}: {}", node.name, e);
             failures.push((node.name.clone(), "stop".to_string(), e));
         }
-        let rm_cmd = format!("docker ps -qa --filter \"label=sm121.recipe={}\" | xargs -r docker rm", recipe_name);
+        let rm_cmd = format!(
+            "docker ps -qa --filter \"label=sm121.recipe={}\" | xargs -r docker rm",
+            recipe_name
+        );
         if let Err(e) = run_ssh(config, node, rm_cmd, dry_run).await {
             tracing::warn!("Remove failed on {}: {}", node.name, e);
             failures.push((node.name.clone(), "rm".to_string(), e));
@@ -76,7 +126,9 @@ pub async fn stop_cluster(config: &Config, cluster: &Cluster, recipe_name: &str,
     }
 
     if !failures.is_empty() {
-        for (name, action, e) in &failures { tracing::warn!("Failed to {} on {}: {}", action, name, e); }
+        for (name, action, e) in &failures {
+            tracing::warn!("Failed to {} on {}: {}", action, name, e);
+        }
         anyhow::bail!("Failed to stop containers on {} node(s)", failures.len());
     }
     info!("✅ Все контейнеры остановлены.");
@@ -88,9 +140,19 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
     println!("\n=== Кластер: {} ===", cluster.name);
     let mut handles = Vec::new();
     for node in &cluster.nodes {
-        let config = config.clone(); let node = node.clone(); let dry = dry_run;
+        let config = config.clone();
+        let node = node.clone();
+        let dry = dry_run;
         handles.push(tokio::spawn(async move {
-            let result = run_ssh_with_retry(&config, &node, "docker ps --filter \"label=sm121\" --format '{{.Names}} {{.Status}}' 2>&1".to_string(), dry, 1).await;
+            let result = run_ssh_with_retry(
+                &config,
+                &node,
+                "docker ps --filter \"label=sm121\" --format '{{.Names}} {{.Status}}' 2>&1"
+                    .to_string(),
+                dry,
+                1,
+            )
+            .await;
             (node.name, node.ib_ip, result)
         }));
     }
@@ -99,41 +161,86 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
         match result {
             Ok((name, ib_ip, Ok(check))) => {
                 print!("{} ({}): ", name, ib_ip);
-                if check.is_empty() && dry_run { continue; }
+                if check.is_empty() && dry_run {
+                    continue;
+                }
                 if check.starts_with("docker:") || check.contains("permission denied") {
                     println!("✗ Docker недоступен");
-                    if let Some(line) = check.lines().next() { println!("  └─ {}", line); }
+                    if let Some(line) = check.lines().next() {
+                        println!("  └─ {}", line);
+                    }
                     all_healthy = false;
                 } else {
                     let containers: Vec<&str> = check.lines().filter(|l| !l.is_empty()).collect();
-                    if containers.is_empty() { println!("✗ Нет контейнеров"); all_healthy = false; }
-                    else { println!("✓ {} контейнер(ов)", containers.len()); for c in containers { println!("  └─ {}", c); } }
+                    if containers.is_empty() {
+                        println!("✗ Нет контейнеров");
+                        all_healthy = false;
+                    } else {
+                        println!("✓ {} контейнер(ов)", containers.len());
+                        for c in containers {
+                            println!("  └─ {}", c);
+                        }
+                    }
                 }
             }
-            Ok((name, ib_ip, Err(e))) => { println!("{} ({}): ✗ SSH ошибка: {}", name, ib_ip, e); all_healthy = false; }
-            Err(join_err) => { println!("✗ Task join error: {}", join_err); all_healthy = false; }
+            Ok((name, ib_ip, Err(e))) => {
+                println!("{} ({}): ✗ SSH ошибка: {}", name, ib_ip, e);
+                all_healthy = false;
+            }
+            Err(join_err) => {
+                println!("✗ Task join error: {}", join_err);
+                all_healthy = false;
+            }
         }
     }
-    if !dry_run && all_healthy { info!("✅ Кластер здоров!"); } else if !dry_run { info!("⚠️ Кластер имеет проблемы"); }
+    if !dry_run && all_healthy {
+        info!("✅ Кластер здоров!");
+    } else if !dry_run {
+        info!("⚠️ Кластер имеет проблемы");
+    }
     Ok(())
 }
 
 /// Starts containers on all nodes in parallel.
-async fn start_cluster(config: &Config, cluster: &Cluster, nccl: &NcclConfig, recipe_name: &str, recipe: &Recipe, dry_run: bool) -> Result<()> {
+async fn start_cluster(
+    config: &Config,
+    cluster: &Cluster,
+    nccl: &NcclConfig,
+    recipe_name: &str,
+    recipe: &Recipe,
+    dry_run: bool,
+) -> Result<()> {
     let nnodes = cluster.nodes.len() as u32;
-    let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0 { nnodes } else { recipe.pipeline_parallel_size.unwrap() };
+    let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0 {
+        nnodes
+    } else {
+        recipe.pipeline_parallel_size.unwrap()
+    };
     let mut handles = Vec::new();
 
     for node in &cluster.nodes {
-        let node = node.clone(); let cluster = cluster.clone(); let nccl = nccl.clone();
-        let recipe = recipe.clone(); let config = config.clone();
-        let rn = recipe_name.to_string(); let dry = dry_run; let engine = recipe.engine.clone();
+        let node = node.clone();
+        let cluster = cluster.clone();
+        let nccl = nccl.clone();
+        let recipe = recipe.clone();
+        let config = config.clone();
+        let rn = recipe_name.to_string();
+        let dry = dry_run;
+        let engine = recipe.engine.clone();
         let is_head = node.rank == 0;
-        let cleanup = format!("docker rm -f sm121_{}_{} 2>/dev/null; true", recipe_name, node.rank);
+        let cleanup = format!(
+            "docker rm -f sm121_{}_{} 2>/dev/null; true",
+            recipe_name, node.rank
+        );
 
         handles.push(tokio::spawn(async move {
-            if !dry { let _ = run_ssh(&config, &node, cleanup, dry).await; }
-            let cmd = build_docker_command(&cluster, &nccl, &recipe, &rn, &node, is_head, pp_size, nnodes).join(" ");
+            if !dry {
+                let _ = run_ssh(&config, &node, cleanup, dry).await;
+            }
+            let cmd = build_docker_command(
+                &cluster, &nccl, &recipe, &rn, &node, is_head, pp_size, nnodes,
+            )
+            .join(" ");
             let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
             (node.name, node.rank, engine, result)
         }));
@@ -141,26 +248,67 @@ async fn start_cluster(config: &Config, cluster: &Cluster, nccl: &NcclConfig, re
 
     for result in futures::future::join_all(handles).await {
         match result {
-            Ok((name, rank, engine, Ok(_))) => info!("✅ {} запущен на {} (rank {})", engine.to_uppercase(), name, rank),
-            Ok((name, rank, engine, Err(e))) => anyhow::bail!("Failed to start {} on {} (rank {}): {}", engine.to_uppercase(), name, rank, e),
+            Ok((name, rank, engine, Ok(_))) => info!(
+                "✅ {} запущен на {} (rank {})",
+                engine.to_uppercase(),
+                name,
+                rank
+            ),
+            Ok((name, rank, engine, Err(e))) => anyhow::bail!(
+                "Failed to start {} on {} (rank {}): {}",
+                engine.to_uppercase(),
+                name,
+                rank,
+                e
+            ),
             Err(e) => anyhow::bail!("Task join error: {}", e),
         }
     }
 
-    if !dry_run { sleep(Duration::from_secs(10)).await; info!("🎉 Кластер успешно запущен! API → http://{}:{}", cluster.master_ib_ip, recipe.port); }
+    if !dry_run {
+        sleep(Duration::from_secs(10)).await;
+        info!(
+            "🎉 Кластер успешно запущен! API → http://{}:{}",
+            cluster.master_ib_ip, recipe.port
+        );
+    }
     Ok(())
 }
 
 #[allow(clippy::too_many_arguments)]
-fn build_docker_command(cluster: &Cluster, nccl: &NcclConfig, recipe: &Recipe, recipe_name: &str, node: &Node, is_head: bool, pp_size: u32, nnodes: u32) -> Vec<String> {
+fn build_docker_command(
+    cluster: &Cluster,
+    nccl: &NcclConfig,
+    recipe: &Recipe,
+    recipe_name: &str,
+    node: &Node,
+    is_head: bool,
+    pp_size: u32,
+    nnodes: u32,
+) -> Vec<String> {
+    let is_sglang = recipe.engine == "sglang";
+
     let mut cmd = vec![
         "docker run -d --gpus all --ipc=host --privileged --network=host".to_string(),
-        format!("-e NCCL_IB_DISABLE={}", nccl.ib_disable), format!("-e NCCL_IB_HCA={}", nccl.ib_hca),
-        format!("-e NCCL_SOCKET_IFNAME={}", nccl.socket_ifname), format!("-e NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
-        format!("-e VLLM_HOST_IP={}", node.ib_ip),
+        format!("-e NCCL_IB_DISABLE={}", nccl.ib_disable),
+        format!("-e NCCL_IB_HCA={}", nccl.ib_hca),
+        format!("-e NCCL_SOCKET_IFNAME={}", nccl.socket_ifname),
+        format!("-e NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
     ];
-    for (k, v) in &recipe.env { cmd.push(format!("-e {}={}", k, v)); }
-    if is_head { cmd.push(format!("-p {}:{}", recipe.port, recipe.port)); }
+
+    // Engine-specific host IP env var
+    if is_sglang {
+        cmd.push(format!("-e SGLANG_WORKER_IP={}", node.ib_ip));
+    } else {
+        cmd.push(format!("-e VLLM_HOST_IP={}", node.ib_ip));
+    }
+
+    for (k, v) in &recipe.env {
+        cmd.push(format!("-e {}={}", k, v));
+    }
+    if is_head {
+        cmd.push(format!("-p {}:{}", recipe.port, recipe.port));
+    }
     cmd.push(format!("--name sm121_{}_{}", recipe_name, node.rank));
     cmd.push(format!("--label sm121.recipe={}", recipe_name));
     cmd.push(format!("--label sm121.rank={}", node.rank));
@@ -169,22 +317,56 @@ fn build_docker_command(cluster: &Cluster, nccl: &NcclConfig, recipe: &Recipe, r
         cmd.push(format!("-e RAY_PORT={}", recipe.port));
     }
     cmd.push(recipe.docker_image.clone());
-    if !recipe.entrypoint.is_empty() { cmd.extend(recipe.entrypoint.clone()); }
-    cmd.push(recipe.model.clone());
+    if !recipe.entrypoint.is_empty() {
+        cmd.extend(recipe.entrypoint.clone());
+    }
+
+    // Model argument - engine-specific
+    if is_sglang {
+        cmd.push("--model-path".to_string());
+        cmd.push(recipe.model.clone());
+    } else {
+        cmd.push(recipe.model.clone());
+    }
+
     cmd.extend(recipe.args.clone());
 
+    // Distributed flags - engine-specific
     let flags: Vec<String> = match recipe.distributed_backend.as_str() {
+        "ray" | "mp" if is_sglang => vec![
+            "--tp".into(),
+            recipe.tensor_parallel_size.to_string(),
+            "--nnodes".into(),
+            nnodes.to_string(),
+            "--node-rank".into(),
+            node.rank.to_string(),
+            "--master-addr".into(),
+            cluster.master_ib_ip.clone(),
+        ],
         "ray" | "mp" => vec![
-            "--tensor-parallel-size".into(), "1".into(),
-            "--pipeline-parallel-size".into(), pp_size.to_string(),
-            "--nnodes".into(), nnodes.to_string(),
-            "--node-rank".into(), node.rank.to_string(),
-            "--master-addr".into(), cluster.master_ib_ip.clone(),
-            "--distributed-executor-backend".into(), recipe.distributed_backend.clone(),
+            "--tensor-parallel-size".into(),
+            "1".into(),
+            "--pipeline-parallel-size".into(),
+            pp_size.to_string(),
+            "--nnodes".into(),
+            nnodes.to_string(),
+            "--node-rank".into(),
+            node.rank.to_string(),
+            "--master-addr".into(),
+            cluster.master_ib_ip.clone(),
+            "--distributed-executor-backend".into(),
+            recipe.distributed_backend.clone(),
         ],
         _ => vec![],
     };
     cmd.extend(flags);
+
+    // Port flag — auto-generated for SGLang (all nodes need to bind)
+    if is_sglang {
+        cmd.push("--port".to_string());
+        cmd.push(recipe.port.to_string());
+    }
+
     cmd
 }
 
@@ -192,43 +374,181 @@ fn build_docker_command(cluster: &Cluster, nccl: &NcclConfig, recipe: &Recipe, r
 mod tests {
     use super::*;
     use std::collections::HashMap;
-    fn tcls() -> Cluster { Cluster { name: "t".into(), master_ib_ip: "1".into(), nodes: vec![
-        Node { name: "n0".into(), ssh_ip: "10.0.0.1".into(), ib_ip: "1".into(), rank: 0, host_key: None },
-        Node { name: "n1".into(), ssh_ip: "10.0.0.2".into(), ib_ip: "2".into(), rank: 1, host_key: None },
-    ] } }
-    fn tnccl() -> NcclConfig { NcclConfig { ib_hca: "r".into(), socket_ifname: "e".into(), ib_gid_index: 3, ib_disable: 0 } }
-    fn trec() -> Recipe { Recipe {
-        engine: "vllm".into(), name: "T".into(), docker_image: "img".into(), model: "mdl".into(), port: 8000,
-        required_nodes: Some(2), pipeline_parallel_size: Some(2),
-        args: vec!["--x".into(), "4096".into()], env: HashMap::from([("K".into(), "V".into())]),
-        cluster: None, entrypoint: vec![], distributed_backend: "mp".into(),
-    } }
+    fn tcls() -> Cluster {
+        Cluster {
+            name: "t".into(),
+            master_ib_ip: "1".into(),
+            nodes: vec![
+                Node {
+                    name: "n0".into(),
+                    ssh_ip: "10.0.0.1".into(),
+                    ib_ip: "1".into(),
+                    rank: 0,
+                    host_key: None,
+                },
+                Node {
+                    name: "n1".into(),
+                    ssh_ip: "10.0.0.2".into(),
+                    ib_ip: "2".into(),
+                    rank: 1,
+                    host_key: None,
+                },
+            ],
+        }
+    }
+    fn tnccl() -> NcclConfig {
+        NcclConfig {
+            ib_hca: "r".into(),
+            socket_ifname: "e".into(),
+            ib_gid_index: 3,
+            ib_disable: 0,
+        }
+    }
+    fn trec() -> Recipe {
+        Recipe {
+            engine: "vllm".into(),
+            name: "T".into(),
+            docker_image: "img".into(),
+            model: "mdl".into(),
+            port: 8000,
+            required_nodes: Some(2),
+            pipeline_parallel_size: Some(2),
+            tensor_parallel_size: 1,
+            args: vec!["--x".into(), "4096".into()],
+            env: HashMap::from([("K".into(), "V".into())]),
+            cluster: None,
+            entrypoint: vec![],
+            distributed_backend: "mp".into(),
+        }
+    }
     fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
         build_docker_command(&tcls(), &tnccl(), recipe, "r", node, head, 2, 2).join(" ")
     }
 
-    #[test] fn mp() { let cmd = run(&trec(), &tcls().nodes[0], true);
-        assert!(cmd.contains("docker run -d --gpus all")); assert!(cmd.contains("-e NCCL_IB_HCA=r"));
-        assert!(cmd.contains("-e K=V")); assert!(cmd.contains("-p 8000:8000")); assert!(cmd.contains("--name sm121_r_0"));
-        assert!(cmd.contains("--label sm121.recipe=r")); assert!(cmd.contains("--label sm121.rank=0"));
-        assert!(cmd.contains("img")); assert!(cmd.contains("mdl")); assert!(cmd.contains("--tensor-parallel-size 1"));
-        assert!(cmd.contains("--pipeline-parallel-size 2")); assert!(cmd.contains("--nnodes 2"));
-        assert!(cmd.contains("--node-rank 0")); assert!(cmd.contains("--master-addr 1"));
-        assert!(cmd.contains("--distributed-executor-backend mp")); assert!(!cmd.contains("RAY_ADDRESS"));
+    #[test]
+    fn mp() {
+        let cmd = run(&trec(), &tcls().nodes[0], true);
+        assert!(cmd.contains("docker run -d --gpus all"));
+        assert!(cmd.contains("-e NCCL_IB_HCA=r"));
+        assert!(cmd.contains("-e K=V"));
+        assert!(cmd.contains("-p 8000:8000"));
+        assert!(cmd.contains("--name sm121_r_0"));
+        assert!(cmd.contains("--label sm121.recipe=r"));
+        assert!(cmd.contains("--label sm121.rank=0"));
+        assert!(cmd.contains("img"));
+        assert!(cmd.contains("mdl"));
+        assert!(cmd.contains("--tensor-parallel-size 1"));
+        assert!(cmd.contains("--pipeline-parallel-size 2"));
+        assert!(cmd.contains("--nnodes 2"));
+        assert!(cmd.contains("--node-rank 0"));
+        assert!(cmd.contains("--master-addr 1"));
+        assert!(cmd.contains("--distributed-executor-backend mp"));
+        assert!(!cmd.contains("RAY_ADDRESS"));
     }
-    #[test] fn worker_no_port() { let cmd = run(&trec(), &tcls().nodes[1], false);
-        assert!(!cmd.contains("-p 8000:8000")); assert!(cmd.contains("--node-rank 1"));
-        assert!(cmd.contains("--label sm121.rank=1")); assert!(cmd.contains("--name sm121_r_1"));
+    #[test]
+    fn worker_no_port() {
+        let cmd = run(&trec(), &tcls().nodes[1], false);
+        assert!(!cmd.contains("-p 8000:8000"));
+        assert!(cmd.contains("--node-rank 1"));
+        assert!(cmd.contains("--label sm121.rank=1"));
+        assert!(cmd.contains("--name sm121_r_1"));
     }
-    #[test] fn ray_backend() { let mut r = trec(); r.distributed_backend = "ray".into();
+    #[test]
+    fn ray_backend() {
+        let mut r = trec();
+        r.distributed_backend = "ray".into();
         let cmd = run(&r, &tcls().nodes[0], true);
-        assert!(cmd.contains("-e RAY_ADDRESS=1:6379")); assert!(cmd.contains("-e RAY_PORT=8000"));
-        assert!(cmd.contains("--node-rank 0")); assert!(cmd.contains("--distributed-executor-backend ray"));
+        assert!(cmd.contains("-e RAY_ADDRESS=1:6379"));
+        assert!(cmd.contains("-e RAY_PORT=8000"));
+        assert!(cmd.contains("--node-rank 0"));
+        assert!(cmd.contains("--distributed-executor-backend ray"));
+    }
+    #[test]
+    fn auto_pp_size() {
+        let cmd = run(&trec(), &tcls().nodes[0], true);
+        assert!(cmd.contains("--pipeline-parallel-size 2"));
     }
-    #[test] fn auto_pp_size() { let cmd = run(&trec(), &tcls().nodes[0], true); assert!(cmd.contains("--pipeline-parallel-size 2")); }
-    #[test] fn entrypoint() { let mut r = trec(); r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
+    #[test]
+    fn entrypoint() {
+        let mut r = trec();
+        r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
         let cmd = run(&r, &tcls().nodes[0], true);
-        let ip = cmd.find("img").unwrap(); let mp = cmd.find("mdl").unwrap(); let ep = cmd.find("py -m mod").unwrap();
-        assert!(ip < ep && ep < mp, "entrypoint should be between image and model");
+        let ip = cmd.find("img").unwrap();
+        let mp = cmd.find("mdl").unwrap();
+        let ep = cmd.find("py -m mod").unwrap();
+        assert!(
+            ip < ep && ep < mp,
+            "entrypoint should be between image and model"
+        );
+    }
+
+    // ── SGLang engine tests ──────────────────────────────────────
+    fn sglang_recipe() -> Recipe {
+        Recipe {
+            engine: "sglang".into(),
+            name: "S".into(),
+            docker_image: "sglang-img".into(),
+            model: "org/sgl-model".into(),
+            port: 8000,
+            required_nodes: Some(4),
+            pipeline_parallel_size: None,
+            tensor_parallel_size: 4,
+            args: vec!["--mem-fraction-static".into(), "0.8".into()],
+            env: HashMap::from([("TORCH_NCCL_ASYNC_ERROR_HANDLING".into(), "1".into())]),
+            cluster: None,
+            entrypoint: vec!["sglang".into(), "serve".into()],
+            distributed_backend: "mp".into(),
+        }
+    }
+    fn srun(recipe: &Recipe, node: &Node, head: bool) -> String {
+        build_docker_command(&tcls(), &tnccl(), recipe, "sg", node, head, 4, 4).join(" ")
+    }
+
+    #[test]
+    fn sglang_host_ip() {
+        let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
+        assert!(cmd.contains("-e SGLANG_WORKER_IP=1"));
+        assert!(!cmd.contains("VLLM_HOST_IP"));
+    }
+
+    #[test]
+    fn sglang_head() {
+        let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
+        assert!(cmd.contains("--model-path"));
+        assert!(cmd.contains("org/sgl-model"));
+        assert!(cmd.contains("--tp 4"));
+        assert!(cmd.contains("--nnodes 4"));
+        assert!(cmd.contains("--node-rank 0"));
+        assert!(cmd.contains("--master-addr 1"));
+        assert!(!cmd.contains("--tensor-parallel-size"));
+        assert!(!cmd.contains("--pipeline-parallel-size"));
+        assert!(!cmd.contains("--distributed-executor-backend"));
+        assert!(cmd.contains("-p 8000:8000"));
+        assert!(cmd.contains("-e TORCH_NCCL_ASYNC_ERROR_HANDLING=1"));
+        assert!(cmd.contains("--port 8000"));
+    }
+
+    #[test]
+    fn sglang_worker() {
+        let cmd = srun(&sglang_recipe(), &tcls().nodes[1], false);
+        assert!(!cmd.contains("-p 8000:8000"));
+        assert!(cmd.contains("--node-rank 1"));
+        assert!(cmd.contains("--port 8000"));
+    }
+
+    #[test]
+    fn sglang_entrypoint_model_path_order() {
+        let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
+        let ep = cmd.find("sglang serve").unwrap();
+        let mp = cmd.find("--model-path").unwrap();
+        let mdl = cmd.find("org/sgl-model").unwrap();
+        let tp = cmd.find("--tp 4").unwrap();
+        assert!(
+            ep < mp && mp < mdl,
+            "entrypoint before model-path before model"
+        );
+        assert!(mdl < tp, "model before --tp");
+        let port = cmd.find("--port 8000").unwrap();
+        assert!(tp < port, "--tp before --port");
     }
 }

+ 49 - 22
src/doctor.rs

@@ -42,7 +42,10 @@ pub async fn doctor(
     dry_run: bool,
 ) -> Result<()> {
     let recipe = load_recipe(recipe_name).await?;
-    println!("\n🩺 Doctor: {} ({}) [backend: {}]", recipe.name, recipe.engine, recipe.distributed_backend);
+    println!(
+        "\n🩺 Doctor: {} ({}) [backend: {}]",
+        recipe.name, recipe.engine, recipe.distributed_backend
+    );
 
     let (cluster, _nccl) = resolve_cluster(config, &recipe, cluster_name)?;
 
@@ -106,9 +109,18 @@ async fn check_node(
 
     checks.push(node_check(config, node, "Docker", "docker --version").await);
     checks.push(node_check(config, node, "NVIDIA GPU", "nvidia-smi").await);
-    checks.push(node_check(config, node, "NCCL libs", "ldconfig -p | grep -q nccl && echo OK").await);
+    checks.push(
+        node_check(
+            config,
+            node,
+            "NCCL libs",
+            "ldconfig -p | grep -q nccl && echo OK",
+        )
+        .await,
+    );
 
-    checks.push(container_check(config, node, image, "Engine", format!("{} version", engine)).await);
+    checks
+        .push(container_check(config, node, image, "Engine", format!("{} version", engine)).await);
 
     if backend == "ray" {
         checks.push(container_check(config, node, image, "Ray", "ray --version".to_string()).await);
@@ -128,7 +140,10 @@ async fn node_check(config: &Config, node: &Node, name: &str, command: &str) ->
         Ok(output) if !output.trim().is_empty() => DoctorCheck {
             name: name.to_string(),
             status: DoctorStatus::Ok,
-            detail: format!("OK: {}", output.trim().lines().next().unwrap_or(output.trim())),
+            detail: format!(
+                "OK: {}",
+                output.trim().lines().next().unwrap_or(output.trim())
+            ),
         },
         Ok(_) => DoctorCheck {
             name: name.to_string(),
@@ -144,17 +159,22 @@ async fn node_check(config: &Config, node: &Node, name: &str, command: &str) ->
 }
 
 /// Run a command inside a container on the node and return a check result.
-async fn container_check(config: &Config, node: &Node, image: &str, name: &str, command: String) -> DoctorCheck {
-    let docker_cmd = format!(
-        "docker run --rm {} {} 2>&1 | head -1",
-        image, command
-    );
+async fn container_check(
+    config: &Config,
+    node: &Node,
+    image: &str,
+    name: &str,
+    command: String,
+) -> DoctorCheck {
+    let docker_cmd = format!("docker run --rm {} {} 2>&1 | head -1", image, command);
     match run_ssh(config, node, docker_cmd, false).await {
-        Ok(output) if !output.contains("not found") && !output.contains("No such file") => DoctorCheck {
-            name: name.to_string(),
-            status: DoctorStatus::Ok,
-            detail: format!("OK: {}", output.trim()),
-        },
+        Ok(output) if !output.contains("not found") && !output.contains("No such file") => {
+            DoctorCheck {
+                name: name.to_string(),
+                status: DoctorStatus::Ok,
+                detail: format!("OK: {}", output.trim()),
+            }
+        }
         Ok(output) => DoctorCheck {
             name: name.to_string(),
             status: DoctorStatus::Fail,
@@ -211,7 +231,8 @@ async fn run_cluster_repair(
     let dockerfile = generate_repair_dockerfile(&repaired_tag, recipe);
 
     let dockerfile_path = format!("recipes/{}/Dockerfile.repaired", "temp");
-    tokio::fs::write(&dockerfile_path, &dockerfile).await
+    tokio::fs::write(&dockerfile_path, &dockerfile)
+        .await
         .context("Failed to write Dockerfile")?;
 
     let create_dockerfile = format!(
@@ -222,15 +243,16 @@ async fn run_cluster_repair(
     if !dry_run {
         run_ssh(config, head_node, create_dockerfile, false).await?;
 
-        let build_cmd = format!(
-            "cd /tmp/sm121_docker && docker build -t {} .",
-            repaired_tag
-        );
-        let output = run_ssh_with_retry(config, head_node, build_cmd, false, 1).await
+        let build_cmd = format!("cd /tmp/sm121_docker && docker build -t {} .", repaired_tag);
+        let output = run_ssh_with_retry(config, head_node, build_cmd, false, 1)
+            .await
             .context("Failed to build repaired image")?;
         println!("Build output: {}", output.lines().last().unwrap_or(&output));
     } else {
-        println!("  (dry-run) Would build {} on {}", repaired_tag, head_node.name);
+        println!(
+            "  (dry-run) Would build {} on {}",
+            repaired_tag, head_node.name
+        );
     }
 
     tokio::fs::remove_file(&dockerfile_path).await.ok();
@@ -259,7 +281,11 @@ fn generate_repair_dockerfile(base_image: &str, recipe: &Recipe) -> String {
 }
 
 /// Check if a repaired image exists on the head node.
-pub async fn try_repaired_image(config: &Config, cluster: &Cluster, recipe: &Recipe) -> Result<String> {
+pub async fn try_repaired_image(
+    config: &Config,
+    cluster: &Cluster,
+    recipe: &Recipe,
+) -> Result<String> {
     let head_node = cluster.nodes.first().context("No nodes in cluster")?;
     let repaired_tag = format!("{}.repaired", recipe.docker_image);
 
@@ -290,6 +316,7 @@ mod tests {
             port: 8000,
             required_nodes: Some(2),
             pipeline_parallel_size: Some(2),
+            tensor_parallel_size: 1,
             args: vec!["--max-model-len".to_string(), "4096".to_string()],
             env: HashMap::new(),
             cluster: None,

+ 5 - 9
src/main.rs

@@ -10,7 +10,7 @@ mod ssh;
 mod tmux;
 
 use crate::cli::{Cli, Commands};
-use crate::config::{Config, Cluster, select_cluster_by_name, resolve_cluster_for_recipe};
+use crate::config::{Cluster, Config, resolve_cluster_for_recipe, select_cluster_by_name};
 use crate::ssh::fetch_host_key;
 
 #[tokio::main]
@@ -47,14 +47,10 @@ async fn main() -> Result<()> {
                     println!("\n=== Кластер: {} ===", cluster.name);
                     for node in &cluster.nodes {
                         match fetch_host_key(&config, node).await {
-                            Ok(fp) => println!(
-                                "{} ({}) -> host_key = \"{}\"",
-                                node.name, node.ssh_ip, fp
-                            ),
-                            Err(e) => println!(
-                                "{} ({}) -> error: {}",
-                                node.name, node.ssh_ip, e
-                            ),
+                            Ok(fp) => {
+                                println!("{} ({}) -> host_key = \"{}\"", node.name, node.ssh_ip, fp)
+                            }
+                            Err(e) => println!("{} ({}) -> error: {}", node.name, node.ssh_ip, e),
                         }
                     }
                 }

+ 32 - 19
src/ssh.rs

@@ -1,6 +1,6 @@
 use anyhow::{Context, Result};
-use base64::engine::general_purpose::STANDARD;
 use base64::Engine;
+use base64::engine::general_purpose::STANDARD;
 use russh::keys::{PrivateKeyWithHashAlg, load_secret_key as russh_load_secret_key};
 use russh::*;
 use sha2::{Digest, Sha256};
@@ -17,16 +17,15 @@ pub async fn ssh_session(config: &Config, node: &Node) -> Result<client::Handle<
     let expected_key = Arc::new(std::sync::Mutex::new(node.host_key.clone()));
 
     if expected_key.lock().unwrap().is_none() {
-        warn!("No host_key set for {} — skipping verification (insecure)", node.name);
+        warn!(
+            "No host_key set for {} — skipping verification (insecure)",
+            node.name
+        );
     }
 
     let handler = VerifyingHandler { expected_key };
-    let mut session = russh::client::connect(
-        Arc::new(ssh_config),
-        (node.ssh_ip.as_str(), 22),
-        handler,
-    )
-    .await?;
+    let mut session =
+        russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler).await?;
 
     session
         .authenticate_publickey(
@@ -39,7 +38,12 @@ pub async fn ssh_session(config: &Config, node: &Node) -> Result<client::Handle<
 }
 
 /// Runs a command on a node via SSH and returns stdout.
-pub async fn run_ssh(config: &Config, node: &Node, command: String, dry_run: bool) -> Result<String> {
+pub async fn run_ssh(
+    config: &Config,
+    node: &Node,
+    command: String,
+    dry_run: bool,
+) -> Result<String> {
     if dry_run {
         println!("  ssh {}@{}: {}", config.ssh_user, node.ssh_ip, command);
         return Ok(String::new());
@@ -89,7 +93,12 @@ pub async fn run_ssh_with_retry(
 }
 
 /// Runs a command via SSH and streams output to stdout (for follow mode).
-pub async fn run_ssh_follow(config: &Config, node: &Node, command: String, dry_run: bool) -> Result<()> {
+pub async fn run_ssh_follow(
+    config: &Config,
+    node: &Node,
+    command: String,
+    dry_run: bool,
+) -> Result<()> {
     if dry_run {
         println!("  ssh {}@{}: {} -f", config.ssh_user, node.ssh_ip, command);
         return Ok(());
@@ -119,7 +128,10 @@ pub async fn fetch_host_key(config: &Config, node: &Node) -> Result<String> {
     }
     impl client::Handler for CaptureHandler {
         type Error = russh::Error;
-        async fn check_server_key(&mut self, key: &russh::keys::PublicKey) -> Result<bool, Self::Error> {
+        async fn check_server_key(
+            &mut self,
+            key: &russh::keys::PublicKey,
+        ) -> Result<bool, Self::Error> {
             *self.captured.lock().unwrap() = Some(key.to_string().into_bytes());
             Ok(true)
         }
@@ -127,13 +139,11 @@ pub async fn fetch_host_key(config: &Config, node: &Node) -> Result<String> {
 
     let key = load_secret_key(&config.ssh_key_path)?;
     let ssh_config = russh::client::Config::default();
-    let handler = CaptureHandler { captured: captured_clone };
-    let mut session = russh::client::connect(
-        Arc::new(ssh_config),
-        (node.ssh_ip.as_str(), 22),
-        handler,
-    )
-    .await?;
+    let handler = CaptureHandler {
+        captured: captured_clone,
+    };
+    let mut session =
+        russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler).await?;
 
     session
         .authenticate_publickey(
@@ -142,7 +152,10 @@ pub async fn fetch_host_key(config: &Config, node: &Node) -> Result<String> {
         )
         .await?;
 
-    let blob = captured.lock().unwrap().take()
+    let blob = captured
+        .lock()
+        .unwrap()
+        .take()
         .context("No server key captured")?;
     let hash = Sha256::digest(&blob);
     let fp = format!("SHA256:{}", STANDARD.encode(hash));

+ 111 - 56
src/tmux/mod.rs

@@ -48,14 +48,10 @@ impl TmuxManager {
     }
 
     /// Builds the full model launch command for a given node.
-    pub fn build_launch_command(
-        &self,
-        recipe: &Recipe,
-        node: &Node,
-        cluster: &Cluster,
-    ) -> String {
+    pub fn build_launch_command(&self, recipe: &Recipe, node: &Node, cluster: &Cluster) -> String {
         let nnodes = cluster.nodes.len() as u32;
         let pp_size = recipe.pipeline_parallel_size.unwrap_or(nnodes);
+        let is_sglang = recipe.engine == "sglang";
 
         let mut parts: Vec<String> = Vec::new();
 
@@ -72,51 +68,69 @@ impl TmuxManager {
             }
         }
 
-        // Model
+        // Model — engine-specific
+        if is_sglang {
+            parts.push("--model-path".to_string());
+        }
         parts.push(recipe.model.clone());
 
         // Recipe-specific args
         parts.extend(recipe.args.clone());
 
-        // Distributed flags
-        match recipe.distributed_backend.as_str() {
-            "ray" => {
-                parts.extend(vec![
-                    "--tensor-parallel-size".to_string(),
-                    "1".to_string(),
-                    "--pipeline-parallel-size".to_string(),
-                    pp_size.to_string(),
-                    "--nnodes".to_string(),
-                    nnodes.to_string(),
-                    "--node-rank".to_string(),
-                    node.rank.to_string(),
-                    "--master-addr".to_string(),
-                    cluster.master_ib_ip.clone(),
-                    "--distributed-executor-backend".to_string(),
-                    "ray".to_string(),
-                ]);
-            }
-            _ => {
-                // MP mode (default)
-                parts.extend(vec![
-                    "--tensor-parallel-size".to_string(),
-                    "1".to_string(),
-                    "--pipeline-parallel-size".to_string(),
-                    pp_size.to_string(),
-                    "--nnodes".to_string(),
-                    nnodes.to_string(),
-                    "--node-rank".to_string(),
-                    node.rank.to_string(),
-                    "--master-addr".to_string(),
-                    cluster.master_ib_ip.clone(),
-                    "--distributed-executor-backend".to_string(),
-                    recipe.distributed_backend.clone(),
-                ]);
+        // Distributed flags — engine-specific
+        if is_sglang {
+            // SGLang uses --tp (not --tensor-parallel-size),
+            // and has no --pipeline-parallel-size / --distributed-executor-backend
+            parts.extend(vec![
+                "--tp".to_string(),
+                recipe.tensor_parallel_size.to_string(),
+                "--nnodes".to_string(),
+                nnodes.to_string(),
+                "--node-rank".to_string(),
+                node.rank.to_string(),
+                "--master-addr".to_string(),
+                cluster.master_ib_ip.clone(),
+            ]);
+        } else {
+            match recipe.distributed_backend.as_str() {
+                "ray" => {
+                    parts.extend(vec![
+                        "--tensor-parallel-size".to_string(),
+                        "1".to_string(),
+                        "--pipeline-parallel-size".to_string(),
+                        pp_size.to_string(),
+                        "--nnodes".to_string(),
+                        nnodes.to_string(),
+                        "--node-rank".to_string(),
+                        node.rank.to_string(),
+                        "--master-addr".to_string(),
+                        cluster.master_ib_ip.clone(),
+                        "--distributed-executor-backend".to_string(),
+                        "ray".to_string(),
+                    ]);
+                }
+                _ => {
+                    // MP mode (default)
+                    parts.extend(vec![
+                        "--tensor-parallel-size".to_string(),
+                        "1".to_string(),
+                        "--pipeline-parallel-size".to_string(),
+                        pp_size.to_string(),
+                        "--nnodes".to_string(),
+                        nnodes.to_string(),
+                        "--node-rank".to_string(),
+                        node.rank.to_string(),
+                        "--master-addr".to_string(),
+                        cluster.master_ib_ip.clone(),
+                        "--distributed-executor-backend".to_string(),
+                        recipe.distributed_backend.clone(),
+                    ]);
+                }
             }
         }
 
-        // Port (only on head node / rank 0)
-        if node.rank == 0 {
+        // Port — SGLang needs it on all nodes, vLLM only on head node
+        if is_sglang || node.rank == 0 {
             parts.extend(vec!["--port".to_string(), recipe.port.to_string()]);
         }
 
@@ -160,7 +174,10 @@ impl TmuxManager {
         }
 
         let launch_cmd = self.build_launch_command(recipe, node, cluster);
-        cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, launch_cmd));
+        cmds.push(format!(
+            "tmux send-keys -t {}:0 '{}' Enter",
+            sess, launch_cmd
+        ));
 
         // --- Window 1: GPU monitor ---
         cmds.push(format!("tmux new-window -t {}:1 -n gpu", sess));
@@ -342,8 +359,17 @@ impl TmuxManager {
             return Ok(());
         }
 
-        info!("  Sending Ctrl+C to session '{}' on {}", session_name, node.name);
-        let _ = run_ssh(config, node, format!("tmux send-keys -t {}:0 C-c", session_name), dry_run).await;
+        info!(
+            "  Sending Ctrl+C to session '{}' on {}",
+            session_name, node.name
+        );
+        let _ = run_ssh(
+            config,
+            node,
+            format!("tmux send-keys -t {}:0 C-c", session_name),
+            dry_run,
+        )
+        .await;
 
         let mut exited = false;
         for i in 0..15 {
@@ -357,14 +383,22 @@ impl TmuxManager {
                     let pid = pid_str.trim();
                     if pid.is_empty() || pid == "0" || pid == "No" {
                         exited = true;
-                        info!("  Session '{}' exited gracefully (attempt {})", session_name, i + 1);
+                        info!(
+                            "  Session '{}' exited gracefully (attempt {})",
+                            session_name,
+                            i + 1
+                        );
                         break;
                     }
                     let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
                     match run_ssh(config, node, ps_cmd, dry_run).await {
                         Ok(ps_out) if ps_out.trim().is_empty() => {
                             exited = true;
-                            info!("  Session '{}' process exited (attempt {})", session_name, i + 1);
+                            info!(
+                                "  Session '{}' process exited (attempt {})",
+                                session_name,
+                                i + 1
+                            );
                             break;
                         }
                         _ => continue,
@@ -378,8 +412,17 @@ impl TmuxManager {
         }
 
         if !exited {
-            warn!("  Session '{}' did not exit gracefully, force killing...", session_name);
-            let _ = run_ssh(config, node, format!("tmux kill-session -t {}", session_name), dry_run).await;
+            warn!(
+                "  Session '{}' did not exit gracefully, force killing...",
+                session_name
+            );
+            let _ = run_ssh(
+                config,
+                node,
+                format!("tmux kill-session -t {}", session_name),
+                dry_run,
+            )
+            .await;
         }
 
         Ok(())
@@ -412,9 +455,12 @@ impl TmuxManager {
         let cmd = if follow {
             format!(
                 "tmux pipe-pane -o -t {}:{} 'cat > /tmp/sm121_pipe_{}_{}.log' && tail -f /tmp/sm121_pipe_{}_{}.log",
-                session_name, window,
-                session_name.replace('-', "_"), window,
-                session_name.replace('-', "_"), window
+                session_name,
+                window,
+                session_name.replace('-', "_"),
+                window,
+                session_name.replace('-', "_"),
+                window
             )
         } else {
             format!(
@@ -442,7 +488,10 @@ impl TmuxManager {
             return Ok(());
         }
 
-        info!("  Attaching to '{}' on {}... Press Ctrl+B D to detach.", session_name, node.name);
+        info!(
+            "  Attaching to '{}' on {}... Press Ctrl+B D to detach.",
+            session_name, node.name
+        );
         info!("  (russh does not support interactive PTY. Use the following command directly:)");
         println!(
             "  ssh -t {}@{} tmux attach-session -t {}",
@@ -450,7 +499,9 @@ impl TmuxManager {
         );
         println!();
         info!("  Streaming pane content instead:");
-        let output = self.stream_pane(config, node, session_name, 0, true, dry_run).await?;
+        let output = self
+            .stream_pane(config, node, session_name, 0, true, dry_run)
+            .await?;
         println!("{}", output);
 
         Ok(())
@@ -489,7 +540,11 @@ pub async fn handle_tmux(config: &Config, cmd: TmuxCommands, dry_run: bool) -> R
         TmuxCommands::Status { recipe, cluster } => {
             ops::tmux_status(config, &recipe, &cluster, dry_run).await?;
         }
-        TmuxCommands::Logs { recipe, follow, cluster } => {
+        TmuxCommands::Logs {
+            recipe,
+            follow,
+            cluster,
+        } => {
             ops::tmux_logs(config, &recipe, &cluster, follow, dry_run).await?;
         }
         TmuxCommands::Attach { recipe, cluster } => {

+ 5 - 5
src/tmux/ops.rs

@@ -36,10 +36,7 @@ pub async fn tmux_stop(
     let mut failures = Vec::new();
     for node in &cluster.nodes {
         let sess = manager.session_name(&recipe, node.rank);
-        match manager
-            .graceful_stop(config, node, &sess, dry_run)
-            .await
-        {
+        match manager.graceful_stop(config, node, &sess, dry_run).await {
             Ok(()) => info!("  ✅ Stopped '{}' on {}", sess, node.name),
             Err(e) => {
                 warn!("  ✗ Failed to stop '{}' on {}: {}", sess, node.name, e);
@@ -82,7 +79,10 @@ pub async fn tmux_status(
         session_prefix: recipe_name.to_string(),
     };
 
-    println!("\n📊 TMUX Status: {} on cluster {}", recipe.name, cluster.name);
+    println!(
+        "\n📊 TMUX Status: {} on cluster {}",
+        recipe.name, cluster.name
+    );
     println!("{}", "─".repeat(60));
 
     let report = health::check_cluster_health(&manager, config, &cluster, &recipe, dry_run).await?;

+ 12 - 3
src/tmux/serve.rs

@@ -36,7 +36,10 @@ pub async fn tmux_serve(
         if actual != required {
             anyhow::bail!(
                 "Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes",
-                recipe.name, required, cluster.name, actual
+                recipe.name,
+                required,
+                cluster.name,
+                actual
             );
         }
     }
@@ -127,8 +130,14 @@ pub async fn tmux_serve(
             "🎉 Cluster launched via tmux! API → http://{}:{}",
             cluster.master_ib_ip, recipe.port
         );
-        info!("📌 To view sessions: `cargo run tmux status --recipe {}`", recipe_name);
-        info!("📌 To attach: `cargo run tmux attach --recipe {}`", recipe_name);
+        info!(
+            "📌 To view sessions: `cargo run tmux status --recipe {}`",
+            recipe_name
+        );
+        info!(
+            "📌 To attach: `cargo run tmux attach --recipe {}`",
+            recipe_name
+        );
     }
 
     Ok(())