Browse Source

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 tháng trước cách đây
mục cha
commit
303e0ce9be
12 tập tin đã thay đổi với 905 bổ sung và 246 xóa
  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(())